From 206373a467b4270a5c1338f8d8309b77844c5487 Mon Sep 17 00:00:00 2001 From: Matt Dickoff Date: Thu, 20 Nov 2025 22:35:58 -0800 Subject: [PATCH 01/17] Implement search functionalty in the help command --- .../ArgumentParser/Usage/HelpCommand.swift | 63 +++ .../ArgumentParser/Usage/SearchEngine.swift | 389 ++++++++++++++++++ 2 files changed, 452 insertions(+) create mode 100644 Sources/ArgumentParser/Usage/SearchEngine.swift diff --git a/Sources/ArgumentParser/Usage/HelpCommand.swift b/Sources/ArgumentParser/Usage/HelpCommand.swift index 80e192ffe..87b570ca1 100644 --- a/Sources/ArgumentParser/Usage/HelpCommand.swift +++ b/Sources/ArgumentParser/Usage/HelpCommand.swift @@ -24,12 +24,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 +50,53 @@ 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 searchEngine = SearchEngine( + rootNode: tree, + commandStack: commandStack.isEmpty ? [tree.element] : commandStack, + visibility: visibility + ) + + let results = searchEngine.search(for: term) + + // Format and print results + let output = SearchEngine.formatResults( + results, + term: term, + toolName: toolName, + screenWidth: HelpGenerator.systemScreenWidth + ) + + print(output) } /// Used for testing. @@ -51,12 +111,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 +126,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/SearchEngine.swift b/Sources/ArgumentParser/Usage/SearchEngine.swift new file mode 100644 index 000000000..993c748b6 --- /dev/null +++ b/Sources/ArgumentParser/Usage/SearchEngine.swift @@ -0,0 +1,389 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +#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 + +/// A search result representing a match found in the command tree. +struct SearchResult { + /// The type of match found. + enum MatchType: Equatable { + /// Matched a command name or alias. + case commandName(matchedText: String) + /// Matched text in a command's abstract or discussion. + case commandDescription(matchedText: String) + /// Matched an argument name (e.g., --verbose, -v). + case argumentName(name: String, matchedText: String) + /// Matched text in an argument's help text. + case argumentDescription(name: String, matchedText: String) + /// Matched text in an argument's possible values or default value. + case argumentValue(name: String, matchedText: String) + } + + /// The full path to the command containing this match (e.g., ["mytool", "sub", "command"]). + var commandPath: [String] + + /// The type of match and associated data. + var matchType: MatchType + + /// A snippet of text showing the match in context. + var contextSnippet: String + + /// Returns true if this is a command match (name or description). + var isCommandMatch: Bool { + switch matchType { + case .commandName, .commandDescription: + return true + case .argumentName, .argumentDescription, .argumentValue: + return false + } + } + + /// Returns the display label for this result. + var displayLabel: String { + switch matchType { + case .commandName(let matched): + return "name: \(matched)" + case .commandDescription: + return "description" + case .argumentName(let name, _): + return name + case .argumentDescription(let name, _): + return "\(name): description" + case .argumentValue(let name, _): + return "\(name): value" + } + } +} + +/// Engine for searching through command trees. +struct SearchEngine { + /// The starting point for the search (root or subcommand). + var rootNode: Tree + + /// 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 } + + // Search command name + let commandName = command._commandName + if commandName.lowercased().contains(term) { + results.append(SearchResult( + commandPath: currentPath, + matchType: .commandName(matchedText: commandName), + contextSnippet: commandName + )) + } + + // Search command aliases + for alias in configuration.aliases { + if alias.lowercased().contains(term) { + results.append(SearchResult( + commandPath: currentPath, + matchType: .commandName(matchedText: alias), + contextSnippet: alias + )) + } + } + + // Search command abstract + if !configuration.abstract.isEmpty && configuration.abstract.lowercased().contains(term) { + let snippet = extractSnippet(from: configuration.abstract, around: term) + results.append(SearchResult( + commandPath: currentPath, + matchType: .commandDescription(matchedText: snippet), + contextSnippet: snippet + )) + } + + // Search command discussion + if !configuration.discussion.isEmpty && configuration.discussion.lowercased().contains(term) { + let snippet = extractSnippet(from: configuration.discussion, around: term) + results.append(SearchResult( + commandPath: currentPath, + matchType: .commandDescription(matchedText: snippet), + contextSnippet: snippet + )) + } + + // 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 + + // Search argument names + for name in names { + let nameString = name.synopsisString + if nameString.lowercased().contains(term) { + // Get a display name for this argument + let displayNames = names.map { $0.synopsisString }.joined(separator: ", ") + results.append(SearchResult( + commandPath: commandPath, + matchType: .argumentName(name: displayNames, matchedText: nameString), + contextSnippet: arg.help.abstract + )) + break // Only add once per argument even if multiple names match + } + } + + // Get display name for this argument + let displayNames = names.map { $0.synopsisString }.joined(separator: ", ") + + // Search argument abstract + if !arg.help.abstract.isEmpty && arg.help.abstract.lowercased().contains(term) { + let snippet = extractSnippet(from: arg.help.abstract, around: term) + results.append(SearchResult( + commandPath: commandPath, + matchType: .argumentDescription(name: displayNames, matchedText: snippet), + contextSnippet: snippet + )) + } + + // Search argument discussion + if case .staticText(let discussionText) = arg.help.discussion, + !discussionText.isEmpty && discussionText.lowercased().contains(term) { + let snippet = extractSnippet(from: discussionText, around: term) + results.append(SearchResult( + commandPath: commandPath, + matchType: .argumentDescription(name: displayNames, matchedText: snippet), + contextSnippet: snippet + )) + } + + // Search possible values + for value in arg.help.allValueStrings where !value.isEmpty { + if value.lowercased().contains(term) { + results.append(SearchResult( + commandPath: commandPath, + matchType: .argumentValue(name: displayNames, matchedText: value), + contextSnippet: "possible value: \(value)" + )) + } + } + + // Search default value + if let defaultValue = arg.help.defaultValue, + !defaultValue.isEmpty && defaultValue.lowercased().contains(term) { + results.append(SearchResult( + commandPath: commandPath, + matchType: .argumentValue(name: displayNames, matchedText: defaultValue), + contextSnippet: "default: \(defaultValue)" + )) + } + } + } + + /// 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.range(of: 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." + } + + 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, screenWidth: screenWidth) + } + + // Display argument matches + if !argumentResults.isEmpty { + output += "\nOPTIONS:\n" + output += formatArgumentResults(argumentResults, screenWidth: screenWidth) + } + + output += "\nUse '\(toolName) --help' for detailed information." + + return output + } + + /// Format command search results. + private static func formatCommandResults(_ results: [SearchResult], screenWidth: Int) -> String { + var output = "" + + for result in results { + let pathString = result.commandPath.joined(separator: " ") + output += " \(pathString)\n" + output += " \(result.displayLabel)\n" + if !result.contextSnippet.isEmpty { + let wrapped = result.contextSnippet.wrapped(to: screenWidth, wrappingIndent: 6) + output += " \(wrapped.dropFirst(4))\n" + } + output += "\n" + } + + return output + } + + /// Format argument search results. + private static func formatArgumentResults(_ results: [SearchResult], screenWidth: Int) -> 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 + switch result.matchType { + case .argumentName(let name, _): + let wrapped = result.contextSnippet.wrapped(to: screenWidth, wrappingIndent: 6) + output += " \(name): \(wrapped.dropFirst(6))\n" + case .argumentDescription(let name, _): + let wrapped = result.contextSnippet.wrapped(to: screenWidth, wrappingIndent: 6) + output += " \(name): \(wrapped.dropFirst(6))\n" + case .argumentValue(let name, _): + output += " \(name) (\(result.contextSnippet))\n" + default: + break + } + } + + return output + } +} From caae524721868681418c2e8ff025071b9a58fd05 Mon Sep 17 00:00:00 2001 From: Matt Dickoff Date: Thu, 20 Nov 2025 22:44:31 -0800 Subject: [PATCH 02/17] advertise the presence of --search more prominently --- Sources/ArgumentParser/Usage/HelpCommand.swift | 2 +- Sources/ArgumentParser/Usage/HelpGenerator.swift | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Sources/ArgumentParser/Usage/HelpCommand.swift b/Sources/ArgumentParser/Usage/HelpCommand.swift index 87b570ca1..8c174d62b 100644 --- a/Sources/ArgumentParser/Usage/HelpCommand.swift +++ b/Sources/ArgumentParser/Usage/HelpCommand.swift @@ -12,7 +12,7 @@ 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. diff --git a/Sources/ArgumentParser/Usage/HelpGenerator.swift b/Sources/ArgumentParser/Usage/HelpGenerator.swift index 4fc313042..fa894416d 100644 --- a/Sources/ArgumentParser/Usage/HelpGenerator.swift +++ b/Sources/ArgumentParser/Usage/HelpGenerator.swift @@ -396,6 +396,18 @@ internal struct HelpGenerator { """ } + // Add search hint message + var searchHintMessage = "" + var helpNames = commandStack.map { $0._commandName } + if let superName = commandStack.first?.configuration._superCommandName { + helpNames.insert(superName, at: 0) + } + let toolName = helpNames.joined(separator: " ") + searchHintMessage = """ + + Use '\(toolName) help --search ' to search commands and options. + """ + let renderedUsage = usage.isEmpty ? "" @@ -404,7 +416,7 @@ internal struct HelpGenerator { return """ \(renderedAbstract)\ \(renderedUsage)\ - \(renderedSections)\(helpSubcommandMessage) + \(renderedSections)\(helpSubcommandMessage)\(searchHintMessage) """ } } From 6cd4341ce1e8acef4c07d48bfd22a5bee3dba28c Mon Sep 17 00:00:00 2001 From: Matt Dickoff Date: Thu, 20 Nov 2025 22:55:28 -0800 Subject: [PATCH 03/17] improve search result printing --- .../ArgumentParser/Usage/SearchEngine.swift | 171 +++++++++++------- 1 file changed, 104 insertions(+), 67 deletions(-) diff --git a/Sources/ArgumentParser/Usage/SearchEngine.swift b/Sources/ArgumentParser/Usage/SearchEngine.swift index 993c748b6..7a4c221fc 100644 --- a/Sources/ArgumentParser/Usage/SearchEngine.swift +++ b/Sources/ArgumentParser/Usage/SearchEngine.swift @@ -121,44 +121,53 @@ struct SearchEngine { // Don't search commands that shouldn't be displayed guard configuration.shouldDisplay else { return } - // Search command name + // 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) { - results.append(SearchResult( - commandPath: currentPath, - matchType: .commandName(matchedText: commandName), - contextSnippet: commandName - )) + bestMatchType = .commandName(matchedText: commandName) + bestSnippet = configuration.abstract.isEmpty ? commandName : configuration.abstract + matchFound = true } - // Search command aliases - for alias in configuration.aliases { - if alias.lowercased().contains(term) { - results.append(SearchResult( - commandPath: currentPath, - matchType: .commandName(matchedText: alias), - contextSnippet: alias - )) + // 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 + } } } - // Search command abstract - if !configuration.abstract.isEmpty && configuration.abstract.lowercased().contains(term) { + // 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) - results.append(SearchResult( - commandPath: currentPath, - matchType: .commandDescription(matchedText: snippet), - contextSnippet: snippet - )) + bestMatchType = .commandDescription(matchedText: snippet) + bestSnippet = snippet + matchFound = true } - // Search command discussion - if !configuration.discussion.isEmpty && configuration.discussion.lowercased().contains(term) { + // 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: .commandDescription(matchedText: snippet), - contextSnippet: snippet + matchType: matchType, + contextSnippet: bestSnippet )) } @@ -191,64 +200,75 @@ struct SearchEngine { 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 = "" - // Search argument names + // Check 1: Search argument names (highest priority) for name in names { let nameString = name.synopsisString if nameString.lowercased().contains(term) { - // Get a display name for this argument - let displayNames = names.map { $0.synopsisString }.joined(separator: ", ") - results.append(SearchResult( - commandPath: commandPath, - matchType: .argumentName(name: displayNames, matchedText: nameString), - contextSnippet: arg.help.abstract - )) - break // Only add once per argument even if multiple names match + bestMatchType = .argumentName(name: displayNames, matchedText: nameString) + bestSnippet = arg.help.abstract + matchFound = true + break } } - // Get display name for this argument - let displayNames = names.map { $0.synopsisString }.joined(separator: ", ") - - // Search argument abstract - if !arg.help.abstract.isEmpty && arg.help.abstract.lowercased().contains(term) { + // 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) - results.append(SearchResult( - commandPath: commandPath, - matchType: .argumentDescription(name: displayNames, matchedText: snippet), - contextSnippet: snippet - )) + bestMatchType = .argumentDescription(name: displayNames, matchedText: snippet) + bestSnippet = snippet + matchFound = true } - // Search argument discussion - if case .staticText(let discussionText) = arg.help.discussion, + // 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) - results.append(SearchResult( - commandPath: commandPath, - matchType: .argumentDescription(name: displayNames, matchedText: snippet), - contextSnippet: snippet - )) + bestMatchType = .argumentDescription(name: displayNames, matchedText: snippet) + bestSnippet = snippet + matchFound = true } - // Search possible values - for value in arg.help.allValueStrings where !value.isEmpty { - if value.lowercased().contains(term) { - results.append(SearchResult( - commandPath: commandPath, - matchType: .argumentValue(name: displayNames, matchedText: value), - contextSnippet: "possible value: \(value)" - )) + // 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 + } } } - // Search default value - if let defaultValue = arg.help.defaultValue, + // 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: .argumentValue(name: displayNames, matchedText: defaultValue), - contextSnippet: "default: \(defaultValue)" + matchType: matchType, + contextSnippet: bestSnippet )) } } @@ -343,12 +363,29 @@ struct SearchEngine { for result in results { let pathString = result.commandPath.joined(separator: " ") - output += " \(pathString)\n" - output += " \(result.displayLabel)\n" - if !result.contextSnippet.isEmpty { + + switch result.matchType { + case .commandName(let matched): + // For command name matches, show path and description inline if available + if !result.contextSnippet.isEmpty && result.contextSnippet != matched { + output += " \(pathString)\n" + let wrapped = result.contextSnippet.wrapped(to: screenWidth, wrappingIndent: 6) + output += " \(wrapped.dropFirst(6))\n" + } else { + // No description available, just show the path + output += " \(pathString)\n" + } + + case .commandDescription: + // For description matches, show path and the matching snippet + output += " \(pathString)\n" let wrapped = result.contextSnippet.wrapped(to: screenWidth, wrappingIndent: 6) - output += " \(wrapped.dropFirst(4))\n" + output += " \(wrapped.dropFirst(6))\n" + + default: + break } + output += "\n" } From 89b9f6e9735cb857bcc9f0428c07aceb53febd17 Mon Sep 17 00:00:00 2001 From: Matt Dickoff Date: Thu, 20 Nov 2025 23:01:50 -0800 Subject: [PATCH 04/17] support *bolding* the search matches via terminal ansii codes --- .../ArgumentParser/Usage/SearchEngine.swift | 117 +++++++++++++++--- .../ArgumentParser/Utilities/Platform.swift | 15 +++ 2 files changed, 115 insertions(+), 17 deletions(-) diff --git a/Sources/ArgumentParser/Usage/SearchEngine.swift b/Sources/ArgumentParser/Usage/SearchEngine.swift index 7a4c221fc..2925e768f 100644 --- a/Sources/ArgumentParser/Usage/SearchEngine.swift +++ b/Sources/ArgumentParser/Usage/SearchEngine.swift @@ -23,6 +23,68 @@ import Foundation #endif #endif +/// ANSI terminal formatting codes. +enum ANSICode { + /// Start bold text. + static let bold = "\u{001B}[1m" + /// Reset all formatting. + static let reset = "\u{001B}[0m" + + /// Highlight a string by making it bold. + /// + /// - Parameters: + /// - text: The text to highlight. + /// - enabled: Whether to actually apply formatting (false when not outputting to a terminal). + /// - Returns: The text with bold formatting if enabled, otherwise unchanged. + static func highlight(_ text: String, enabled: Bool) -> String { + enabled ? "\(bold)\(text)\(reset)" : text + } + + /// Highlight all occurrences of a search term in text (case-insensitive). + /// + /// - Parameters: + /// - text: The text to search within. + /// - term: The term to highlight. + /// - enabled: Whether to actually apply formatting. + /// - Returns: The text with all matches highlighted. + static func highlightMatches(in text: String, matching term: String, enabled: Bool) -> 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.. String { + private static func formatCommandResults( + _ results: [SearchResult], + term: String, + screenWidth: Int, + useFormatting: Bool + ) -> String { var output = "" for result in results { @@ -366,20 +436,21 @@ struct SearchEngine { switch result.matchType { case .commandName(let matched): - // For command name matches, show path and description inline if available + // For command name matches, show path with highlighted name and description + let highlightedPath = ANSICode.highlightMatches(in: pathString, matching: term, enabled: useFormatting) + output += " \(highlightedPath)\n" + if !result.contextSnippet.isEmpty && result.contextSnippet != matched { - output += " \(pathString)\n" - let wrapped = result.contextSnippet.wrapped(to: screenWidth, wrappingIndent: 6) + let highlightedSnippet = ANSICode.highlightMatches(in: result.contextSnippet, matching: term, enabled: useFormatting) + let wrapped = highlightedSnippet.wrapped(to: screenWidth, wrappingIndent: 6) output += " \(wrapped.dropFirst(6))\n" - } else { - // No description available, just show the path - output += " \(pathString)\n" } case .commandDescription: - // For description matches, show path and the matching snippet + // For description matches, show path and the matching snippet with highlights output += " \(pathString)\n" - let wrapped = result.contextSnippet.wrapped(to: screenWidth, wrappingIndent: 6) + let highlightedSnippet = ANSICode.highlightMatches(in: result.contextSnippet, matching: term, enabled: useFormatting) + let wrapped = highlightedSnippet.wrapped(to: screenWidth, wrappingIndent: 6) output += " \(wrapped.dropFirst(6))\n" default: @@ -393,7 +464,12 @@ struct SearchEngine { } /// Format argument search results. - private static func formatArgumentResults(_ results: [SearchResult], screenWidth: Int) -> String { + private static func formatArgumentResults( + _ results: [SearchResult], + term: String, + screenWidth: Int, + useFormatting: Bool + ) -> String { var output = "" var lastPath = "" @@ -406,16 +482,23 @@ struct SearchEngine { lastPath = pathString } - // Format the match + // Format the match with highlighting switch result.matchType { case .argumentName(let name, _): - let wrapped = result.contextSnippet.wrapped(to: screenWidth, wrappingIndent: 6) - output += " \(name): \(wrapped.dropFirst(6))\n" + let highlightedName = ANSICode.highlightMatches(in: name, matching: term, enabled: useFormatting) + let highlightedSnippet = ANSICode.highlightMatches(in: result.contextSnippet, matching: term, enabled: useFormatting) + let wrapped = highlightedSnippet.wrapped(to: screenWidth, wrappingIndent: 6) + output += " \(highlightedName): \(wrapped.dropFirst(6))\n" + case .argumentDescription(let name, _): - let wrapped = result.contextSnippet.wrapped(to: screenWidth, wrappingIndent: 6) + let highlightedSnippet = ANSICode.highlightMatches(in: result.contextSnippet, matching: term, enabled: useFormatting) + let wrapped = highlightedSnippet.wrapped(to: screenWidth, wrappingIndent: 6) output += " \(name): \(wrapped.dropFirst(6))\n" + case .argumentValue(let name, _): - output += " \(name) (\(result.contextSnippet))\n" + let highlightedSnippet = ANSICode.highlightMatches(in: result.contextSnippet, matching: term, enabled: useFormatting) + output += " \(name) (\(highlightedSnippet))\n" + default: break } 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 + } } From b50ec6c30e7f295e0e686056abdc01349b11b07d Mon Sep 17 00:00:00 2001 From: Matt Dickoff Date: Fri, 21 Nov 2025 08:48:05 -0800 Subject: [PATCH 05/17] cleanup help gen --- .../ArgumentParser/Usage/HelpGenerator.swift | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/Sources/ArgumentParser/Usage/HelpGenerator.swift b/Sources/ArgumentParser/Usage/HelpGenerator.swift index fa894416d..5e9223efe 100644 --- a/Sources/ArgumentParser/Usage/HelpGenerator.swift +++ b/Sources/ArgumentParser/Usage/HelpGenerator.swift @@ -389,25 +389,15 @@ 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. """ } - // Add search hint message - var searchHintMessage = "" - var helpNames = commandStack.map { $0._commandName } - if let superName = commandStack.first?.configuration._superCommandName { - helpNames.insert(superName, at: 0) - } - let toolName = helpNames.joined(separator: " ") - searchHintMessage = """ - - Use '\(toolName) help --search ' to search commands and options. - """ - let renderedUsage = usage.isEmpty ? "" @@ -416,7 +406,7 @@ internal struct HelpGenerator { return """ \(renderedAbstract)\ \(renderedUsage)\ - \(renderedSections)\(helpSubcommandMessage)\(searchHintMessage) + \(renderedSections)\(helpSubcommandMessage) """ } } From 7a5d0e3cc9cad738ed8219a09a298db602de5969 Mon Sep 17 00:00:00 2001 From: Matt Dickoff Date: Fri, 21 Nov 2025 09:19:15 -0800 Subject: [PATCH 06/17] Fix tests --- .../SubcommandEndToEndTests.swift | 1 + .../MathExampleTests.swift | 1 + .../testMathBashCompletionScript().bash | 9 ++++++- .../testMathFishCompletionScript().fish | 5 ++-- .../testMathZshCompletionScript().zsh | 3 ++- .../Snapshots/testColorDoccReference().md | 9 +++++-- .../Snapshots/testColorMarkdownReference().md | 9 +++++-- .../testCountLinesDoccReference().md | 9 +++++-- .../testCountLinesMarkdownReference().md | 9 +++++-- .../Snapshots/testMathDoccReference().md | 9 +++++-- .../Snapshots/testMathMarkdownReference().md | 9 +++++-- .../Snapshots/testRepeatDoccReference().md | 9 +++++-- .../testRepeatMarkdownReference().md | 9 +++++-- .../Snapshots/testRollDoccReference().md | 9 +++++-- .../Snapshots/testRollMarkdownReference().md | 9 +++++-- .../Snapshots/testColorMultiPageManual().mdoc | 5 +++- .../testColorSinglePageManual().mdoc | 4 ++- .../testCountLinesMultiPageManual().mdoc | 5 +++- .../testCountLinesSinglePageManual().mdoc | 4 ++- .../Snapshots/testMathMultiPageManual().mdoc | 5 +++- .../Snapshots/testMathSinglePageManual().mdoc | 4 ++- .../testRepeatMultiPageManual().mdoc | 5 +++- .../testRepeatSinglePageManual().mdoc | 4 ++- .../Snapshots/testRollMultiPageManual().mdoc | 5 +++- .../Snapshots/testRollSinglePageManual().mdoc | 4 ++- .../HelpTests.swift | 3 +++ .../HelpGenerationTests+GroupName.swift | 2 ++ .../HelpGenerationTests.swift | 5 ++++ .../Snapshots/testADumpHelp().json | 25 ++++++++++++++++++- .../Snapshots/testBDumpHelp().json | 25 ++++++++++++++++++- .../Snapshots/testBase_Bash().bash | 11 +++++++- .../Snapshots/testBase_Fish().fish | 7 +++--- .../Snapshots/testBase_Zsh().zsh | 3 ++- .../Snapshots/testCDumpHelp().json | 25 ++++++++++++++++++- .../Snapshots/testMathAddDumpHelp().json | 25 ++++++++++++++++++- .../Snapshots/testMathDumpHelp().json | 25 ++++++++++++++++++- .../Snapshots/testMathMultiplyDumpHelp().json | 25 ++++++++++++++++++- .../Snapshots/testMathStatsDumpHelp().json | 25 ++++++++++++++++++- 38 files changed, 314 insertions(+), 46 deletions(-) diff --git a/Tests/ArgumentParserEndToEndTests/SubcommandEndToEndTests.swift b/Tests/ArgumentParserEndToEndTests/SubcommandEndToEndTests.swift index 1de975b21..9b2af4d8a 100644 --- a/Tests/ArgumentParserEndToEndTests/SubcommandEndToEndTests.swift +++ b/Tests/ArgumentParserEndToEndTests/SubcommandEndToEndTests.swift @@ -92,6 +92,7 @@ extension SubcommandEndToEndTests { b See 'foo help ' 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/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, From f554ac43762fcf23735dad9700f8038e6587069b Mon Sep 17 00:00:00 2001 From: Matt Dickoff Date: Fri, 21 Nov 2025 10:16:38 -0800 Subject: [PATCH 07/17] fix named arguments and remove unused func --- .../ArgumentParser/Usage/SearchEngine.swift | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Sources/ArgumentParser/Usage/SearchEngine.swift b/Sources/ArgumentParser/Usage/SearchEngine.swift index 2925e768f..61e725860 100644 --- a/Sources/ArgumentParser/Usage/SearchEngine.swift +++ b/Sources/ArgumentParser/Usage/SearchEngine.swift @@ -30,16 +30,6 @@ enum ANSICode { /// Reset all formatting. static let reset = "\u{001B}[0m" - /// Highlight a string by making it bold. - /// - /// - Parameters: - /// - text: The text to highlight. - /// - enabled: Whether to actually apply formatting (false when not outputting to a terminal). - /// - Returns: The text with bold formatting if enabled, otherwise unchanged. - static func highlight(_ text: String, enabled: Bool) -> String { - enabled ? "\(bold)\(text)\(reset)" : text - } - /// Highlight all occurrences of a search term in text (case-insensitive). /// /// - Parameters: @@ -276,13 +266,23 @@ struct SearchEngine { var bestSnippet = "" // Check 1: Search argument names (highest priority) - for name in names { - let nameString = name.synopsisString - if nameString.lowercased().contains(term) { - bestMatchType = .argumentName(name: displayNames, matchedText: nameString) + 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 - break + } + } 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 + } } } From 81bb56fd3a51da63df09a1023d57cf671f4ce59b Mon Sep 17 00:00:00 2001 From: Matt Dickoff Date: Fri, 21 Nov 2025 11:04:17 -0800 Subject: [PATCH 08/17] More testing --- .../ArgumentParser/Usage/SearchEngine.swift | 30 +- .../SearchEngineTests.swift | 798 ++++++++++++++++++ 2 files changed, 813 insertions(+), 15 deletions(-) create mode 100644 Tests/ArgumentParserUnitTests/SearchEngineTests.swift diff --git a/Sources/ArgumentParser/Usage/SearchEngine.swift b/Sources/ArgumentParser/Usage/SearchEngine.swift index 61e725860..bc85bbede 100644 --- a/Sources/ArgumentParser/Usage/SearchEngine.swift +++ b/Sources/ArgumentParser/Usage/SearchEngine.swift @@ -110,21 +110,21 @@ struct SearchResult { } } - /// Returns the display label for this result. - var displayLabel: String { - switch matchType { - case .commandName(let matched): - return "name: \(matched)" - case .commandDescription: - return "description" - case .argumentName(let name, _): - return name - case .argumentDescription(let name, _): - return "\(name): description" - case .argumentValue(let name, _): - return "\(name): value" - } - } +// /// Returns the display label for this result. +// var displayLabel: String { +// switch matchType { +// case .commandName(let matched): +// return "name: \(matched)" +// case .commandDescription: +// return "description" +// case .argumentName(let name, _): +// return name +// case .argumentDescription(let name, _): +// return "\(name): description" +// case .argumentValue(let name, _): +// return "\(name): value" +// } +// } } /// Engine for searching through command trees. diff --git a/Tests/ArgumentParserUnitTests/SearchEngineTests.swift b/Tests/ArgumentParserUnitTests/SearchEngineTests.swift new file mode 100644 index 000000000..456b3e819 --- /dev/null +++ b/Tests/ArgumentParserUnitTests/SearchEngineTests.swift @@ -0,0 +1,798 @@ +//===----------------------------------------------------------------------===// +// +// 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 SearchEngineTests: 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 + +extension SearchEngineTests { + func testSearch_CommandName() { + let tree = CommandParser(ParentCommand.self).commandTree + let engine = SearchEngine( + 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 = SearchEngine( + 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 = SearchEngine( + 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 = SearchEngine( + 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 + +extension SearchEngineTests { + func testSearch_ArgumentName() { + let tree = CommandParser(SimpleCommand.self).commandTree + let engine = SearchEngine( + 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 = SearchEngine( + 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 = SearchEngine( + 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 = SearchEngine( + 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 = SearchEngine( + 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 = SearchEngine( + 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 = SearchEngine( + 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 = SearchEngine( + 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 + +extension SearchEngineTests { + func testSearch_CaseInsensitive() { + let tree = CommandParser(SimpleCommand.self).commandTree + let engine = SearchEngine( + 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 + +extension SearchEngineTests { + func testSearch_ResultOrdering() { + let tree = CommandParser(ParentCommand.self).commandTree + let engine = SearchEngine( + 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 + +extension SearchEngineTests { + func testSearch_EmptyTerm() { + let tree = CommandParser(SimpleCommand.self).commandTree + let engine = SearchEngine( + 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 = SearchEngine( + rootNode: tree, + commandStack: [SimpleCommand.self], + visibility: .default + ) + + let results = engine.search(for: "xyzzynonexistent") + + XCTAssertEqual(results.count, 0) + } +} + +// MARK: - Priority Tests + +extension SearchEngineTests { + 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 = SearchEngine( + 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 + +extension SearchEngineTests { + func testANSI_Highlight() { + let text = "This is a test string" + let highlighted = ANSICode.highlightMatches(in: text, matching: "test", enabled: true) + + XCTAssertTrue(highlighted.contains(ANSICode.bold)) + XCTAssertTrue(highlighted.contains(ANSICode.reset)) + XCTAssertTrue(highlighted.contains("test")) + } + + 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 + +extension SearchEngineTests { + 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 = SearchEngine( + 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 + +extension SearchEngineTests { + func testFormatResults_NoMatches() { + let formatted = SearchEngine.formatResults( + [], + term: "test", + toolName: "mytool", + screenWidth: 80 + ) + + 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 = SearchEngine( + rootNode: tree, + commandStack: [SimpleCommand.self], + visibility: .default + ) + + let results = engine.search(for: "name") + let formatted = SearchEngine.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 = SearchEngine( + rootNode: tree, + commandStack: [ParentCommand.self], + visibility: .default + ) + + let results = engine.search(for: "child") + let formatted = SearchEngine.formatResults( + results, + term: "child", + toolName: "parent-command", + screenWidth: 80 + ) + + // 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 = SearchEngine( + rootNode: tree, + commandStack: [TestCommand.self], + visibility: .default + ) + + let results = engine.search(for: "operations") + let formatted = SearchEngine.formatResults( + results, + term: "operations", + toolName: "test-command", + screenWidth: 80 + ) + + // 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 = SearchEngine( + rootNode: tree, + commandStack: [TestCommand.self], + visibility: .default + ) + + let results = engine.search(for: "screen width") + let formatted = SearchEngine.formatResults( + results, + term: "screen width", + toolName: "test-command", + screenWidth: 60 // Narrow width to force wrapping + ) + + // 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 = SearchEngine( + rootNode: tree, + commandStack: [TestCommand.self], + visibility: .default + ) + + let results = engine.search(for: "network requests") + let formatted = SearchEngine.formatResults( + results, + term: "network requests", + toolName: "test-command", + screenWidth: 80 + ) + + // 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 = SearchEngine( + rootNode: tree, + commandStack: [TestCommand.self], + visibility: .default + ) + + let results = engine.search(for: "screen width") + let formatted = SearchEngine.formatResults( + results, + term: "screen width", + toolName: "test-command", + screenWidth: 60 // Narrow width to force wrapping + ) + + // 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 = SearchEngine( + rootNode: tree, + commandStack: [TestCommand.self], + visibility: .default + ) + + let results = engine.search(for: "yaml") + let formatted = SearchEngine.formatResults( + results, + term: "yaml", + toolName: "test-command", + screenWidth: 80 + ) + + // 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 = SearchEngine( + rootNode: tree, + commandStack: [TestCommand.self], + visibility: .default + ) + + let results = engine.search(for: "app.log") + let formatted = SearchEngine.formatResults( + results, + term: "app.log", + toolName: "test-command", + screenWidth: 80 + ) + + // 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")) + } +} From 292b65f7bf6eba8c7fe0f25856c9749626da4835 Mon Sep 17 00:00:00 2001 From: Matt Dickoff Date: Fri, 21 Nov 2025 11:31:37 -0800 Subject: [PATCH 09/17] change enablement a bit and change some tests --- .../ArgumentParser/Usage/SearchEngine.swift | 29 ++++++++++--------- .../SearchEngineTests.swift | 29 +++++++++++-------- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/Sources/ArgumentParser/Usage/SearchEngine.swift b/Sources/ArgumentParser/Usage/SearchEngine.swift index bc85bbede..b83f9a256 100644 --- a/Sources/ArgumentParser/Usage/SearchEngine.swift +++ b/Sources/ArgumentParser/Usage/SearchEngine.swift @@ -385,19 +385,20 @@ struct SearchEngine { /// - term: The search term (for display in header). /// - toolName: The name of the root tool. /// - screenWidth: The screen width for formatting. + /// - useHighlighting: Use ANSI codes to highlight results. If not set the results will be highlighted if the output is a terminal. /// - Returns: A formatted string ready for display. static func formatResults( _ results: [SearchResult], term: String, toolName: String, - screenWidth: Int + screenWidth: Int, + useHighlighting: Bool = Platform.isStdoutTerminal ) -> String { guard !results.isEmpty else { return "No matches found for '\(term)'.\nTry '\(toolName) --help' for all options." } - // Check if we should use ANSI formatting - let useFormatting = Platform.isStdoutTerminal + //let useHighlighting = useHighlighting ?? Platform.isStdoutTerminal var output = "Found \(results.count) match\(results.count == 1 ? "" : "es") for '\(term)':\n" @@ -408,13 +409,13 @@ struct SearchEngine { // Display command matches if !commandResults.isEmpty { output += "\nCOMMANDS:\n" - output += formatCommandResults(commandResults, term: term, screenWidth: screenWidth, useFormatting: useFormatting) + 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, useFormatting: useFormatting) + output += formatArgumentResults(argumentResults, term: term, screenWidth: screenWidth, useHighlighting: useHighlighting) } output += "\nUse '\(toolName) --help' for detailed information." @@ -427,7 +428,7 @@ struct SearchEngine { _ results: [SearchResult], term: String, screenWidth: Int, - useFormatting: Bool + useHighlighting: Bool ) -> String { var output = "" @@ -437,11 +438,11 @@ struct SearchEngine { 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: useFormatting) + 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: useFormatting) + let highlightedSnippet = ANSICode.highlightMatches(in: result.contextSnippet, matching: term, enabled: useHighlighting) let wrapped = highlightedSnippet.wrapped(to: screenWidth, wrappingIndent: 6) output += " \(wrapped.dropFirst(6))\n" } @@ -449,7 +450,7 @@ struct SearchEngine { 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: useFormatting) + let highlightedSnippet = ANSICode.highlightMatches(in: result.contextSnippet, matching: term, enabled: useHighlighting) let wrapped = highlightedSnippet.wrapped(to: screenWidth, wrappingIndent: 6) output += " \(wrapped.dropFirst(6))\n" @@ -468,7 +469,7 @@ struct SearchEngine { _ results: [SearchResult], term: String, screenWidth: Int, - useFormatting: Bool + useHighlighting: Bool ) -> String { var output = "" var lastPath = "" @@ -485,18 +486,18 @@ struct SearchEngine { // Format the match with highlighting switch result.matchType { case .argumentName(let name, _): - let highlightedName = ANSICode.highlightMatches(in: name, matching: term, enabled: useFormatting) - let highlightedSnippet = ANSICode.highlightMatches(in: result.contextSnippet, matching: term, enabled: useFormatting) + 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: useFormatting) + 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: useFormatting) + let highlightedSnippet = ANSICode.highlightMatches(in: result.contextSnippet, matching: term, enabled: useHighlighting) output += " \(name) (\(highlightedSnippet))\n" default: diff --git a/Tests/ArgumentParserUnitTests/SearchEngineTests.swift b/Tests/ArgumentParserUnitTests/SearchEngineTests.swift index 456b3e819..f50e9894e 100644 --- a/Tests/ArgumentParserUnitTests/SearchEngineTests.swift +++ b/Tests/ArgumentParserUnitTests/SearchEngineTests.swift @@ -471,10 +471,7 @@ extension SearchEngineTests { func testANSI_Highlight() { let text = "This is a test string" let highlighted = ANSICode.highlightMatches(in: text, matching: "test", enabled: true) - - XCTAssertTrue(highlighted.contains(ANSICode.bold)) - XCTAssertTrue(highlighted.contains(ANSICode.reset)) - XCTAssertTrue(highlighted.contains("test")) + XCTAssertEqual(highlighted,"This is a "+ANSICode.bold+"test"+ANSICode.reset+" string") } func testANSI_HighlightDisabled() { @@ -539,7 +536,8 @@ extension SearchEngineTests { [], term: "test", toolName: "mytool", - screenWidth: 80 + screenWidth: 80, + useHighlighting: false ) XCTAssertTrue(formatted.contains("No matches found")) @@ -581,7 +579,8 @@ extension SearchEngineTests { results, term: "child", toolName: "parent-command", - screenWidth: 80 + screenWidth: 80, + useHighlighting: false ) // Should have COMMANDS section for command matches @@ -607,7 +606,8 @@ extension SearchEngineTests { results, term: "operations", toolName: "test-command", - screenWidth: 80 + screenWidth: 80, + useHighlighting: false ) // Should find the command description match @@ -640,7 +640,8 @@ extension SearchEngineTests { results, term: "screen width", toolName: "test-command", - screenWidth: 60 // Narrow width to force wrapping + screenWidth: 60, // Narrow width to force wrapping + useHighlighting: false ) // Should find the match @@ -676,7 +677,8 @@ extension SearchEngineTests { results, term: "network requests", toolName: "test-command", - screenWidth: 80 + screenWidth: 80, + useHighlighting: false ) // Should find the match @@ -712,7 +714,8 @@ extension SearchEngineTests { results, term: "screen width", toolName: "test-command", - screenWidth: 60 // Narrow width to force wrapping + screenWidth: 60, // Narrow width to force wrapping + useHighlighting: false ) // Should find match @@ -748,7 +751,8 @@ extension SearchEngineTests { results, term: "yaml", toolName: "test-command", - screenWidth: 80 + screenWidth: 80, + useHighlighting: false ) // Should find the match @@ -781,7 +785,8 @@ extension SearchEngineTests { results, term: "app.log", toolName: "test-command", - screenWidth: 80 + screenWidth: 80, + useHighlighting: false ) // Should find the match From 36fe1eb6e1a8f1497c790d6cb884a87c5c00d3b8 Mon Sep 17 00:00:00 2001 From: Matt Dickoff Date: Fri, 21 Nov 2025 14:00:29 -0800 Subject: [PATCH 10/17] format --- .../ArgumentParser/Usage/HelpCommand.swift | 7 +- .../ArgumentParser/Usage/SearchEngine.swift | 167 +++++++++++------- .../SearchEngineTests.swift | 103 +++++++---- 3 files changed, 174 insertions(+), 103 deletions(-) diff --git a/Sources/ArgumentParser/Usage/HelpCommand.swift b/Sources/ArgumentParser/Usage/HelpCommand.swift index 8c174d62b..736bd6693 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. Use --search to find commands and options.", + abstract: + "Show subcommand help information. Use --search to find commands and options.", helpNames: []) /// Any subcommand names provided after the `help` subcommand. @@ -75,7 +76,9 @@ struct HelpCommand: ParsableCommand { if toolName.isEmpty { toolName = tree.element._commandName } - if let root = commandStack.first, let superName = root.configuration._superCommandName { + if let root = commandStack.first, + let superName = root.configuration._superCommandName + { toolName = "\(superName) \(toolName)" } diff --git a/Sources/ArgumentParser/Usage/SearchEngine.swift b/Sources/ArgumentParser/Usage/SearchEngine.swift index b83f9a256..6dbfd07fe 100644 --- a/Sources/ArgumentParser/Usage/SearchEngine.swift +++ b/Sources/ArgumentParser/Usage/SearchEngine.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift Argument Parser open source project // -// Copyright (c) 2020 Apple Inc. and the Swift project authors +// 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 @@ -37,7 +37,9 @@ enum ANSICode { /// - term: The term to highlight. /// - enabled: Whether to actually apply formatting. /// - Returns: The text with all matches highlighted. - static func highlightMatches(in text: String, matching term: String, enabled: Bool) -> String { + static func highlightMatches( + in text: String, matching term: String, enabled: Bool + ) -> String { guard enabled && !term.isEmpty else { return text } let lowercasedText = text.lowercased() @@ -109,22 +111,6 @@ struct SearchResult { return false } } - -// /// Returns the display label for this result. -// var displayLabel: String { -// switch matchType { -// case .commandName(let matched): -// return "name: \(matched)" -// case .commandDescription: -// return "description" -// case .argumentName(let name, _): -// return name -// case .argumentDescription(let name, _): -// return "\(name): description" -// case .argumentValue(let name, _): -// return "\(name): value" -// } -// } } /// Engine for searching through command trees. @@ -149,14 +135,17 @@ struct SearchEngine { var results: [SearchResult] = [] // Traverse the tree starting from rootNode - traverseTree(node: rootNode, currentPath: commandStack.map { $0._commandName }, term: lowercasedTerm, results: &results) + 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: " ") + return lhs.commandPath.joined(separator: " ") + < rhs.commandPath.joined(separator: " ") } } @@ -182,7 +171,8 @@ struct SearchEngine { let commandName = command._commandName if commandName.lowercased().contains(term) { bestMatchType = .commandName(matchedText: commandName) - bestSnippet = configuration.abstract.isEmpty ? commandName : configuration.abstract + bestSnippet = + configuration.abstract.isEmpty ? commandName : configuration.abstract matchFound = true } @@ -191,7 +181,8 @@ struct SearchEngine { for alias in configuration.aliases { if alias.lowercased().contains(term) { bestMatchType = .commandName(matchedText: alias) - bestSnippet = configuration.abstract.isEmpty ? alias : configuration.abstract + bestSnippet = + configuration.abstract.isEmpty ? alias : configuration.abstract matchFound = true break } @@ -199,7 +190,9 @@ struct SearchEngine { } // Check 3: Search command abstract (if name/aliases didn't match) - if !matchFound && !configuration.abstract.isEmpty && configuration.abstract.lowercased().contains(term) { + if !matchFound && !configuration.abstract.isEmpty + && configuration.abstract.lowercased().contains(term) + { let snippet = extractSnippet(from: configuration.abstract, around: term) bestMatchType = .commandDescription(matchedText: snippet) bestSnippet = snippet @@ -207,7 +200,9 @@ struct SearchEngine { } // Check 4: Search command discussion (if nothing else matched) - if !matchFound && !configuration.discussion.isEmpty && configuration.discussion.lowercased().contains(term) { + if !matchFound && !configuration.discussion.isEmpty + && configuration.discussion.lowercased().contains(term) + { let snippet = extractSnippet(from: configuration.discussion, around: term) bestMatchType = .commandDescription(matchedText: snippet) bestSnippet = snippet @@ -216,15 +211,17 @@ struct SearchEngine { // Add result if we found a match if matchFound, let matchType = bestMatchType { - results.append(SearchResult( - commandPath: currentPath, - matchType: matchType, - contextSnippet: bestSnippet - )) + results.append( + SearchResult( + commandPath: currentPath, + matchType: matchType, + contextSnippet: bestSnippet + )) } // Search arguments - searchArguments(command: command, commandPath: currentPath, term: term, results: &results) + searchArguments( + command: command, commandPath: currentPath, term: term, results: &results) // Recursively search children for child in node.children { @@ -249,7 +246,9 @@ struct SearchEngine { for arg in argSet { // Skip if not visible enough - guard arg.help.visibility.isAtLeastAsVisible(as: visibility) else { continue } + guard arg.help.visibility.isAtLeastAsVisible(as: visibility) else { + continue + } let names = arg.names let displayNames: String @@ -269,7 +268,8 @@ struct SearchEngine { if names.isEmpty { // Positional argument - check if term matches value name if arg.valueName.lowercased().contains(term) { - bestMatchType = .argumentName(name: displayNames, matchedText: arg.valueName) + bestMatchType = .argumentName( + name: displayNames, matchedText: arg.valueName) bestSnippet = arg.help.abstract matchFound = true } @@ -278,7 +278,8 @@ struct SearchEngine { for name in names { let nameString = name.synopsisString if nameString.lowercased().contains(term) { - bestMatchType = .argumentName(name: displayNames, matchedText: nameString) + bestMatchType = .argumentName( + name: displayNames, matchedText: nameString) bestSnippet = arg.help.abstract matchFound = true break @@ -287,19 +288,24 @@ struct SearchEngine { } // Check 2: Search argument abstract (if name didn't match) - if !matchFound && !arg.help.abstract.isEmpty && arg.help.abstract.lowercased().contains(term) { + 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) + 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) { + 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) + bestMatchType = .argumentDescription( + name: displayNames, matchedText: snippet) bestSnippet = snippet matchFound = true } @@ -308,7 +314,8 @@ struct SearchEngine { if !matchFound { for value in arg.help.allValueStrings where !value.isEmpty { if value.lowercased().contains(term) { - bestMatchType = .argumentValue(name: displayNames, matchedText: value) + bestMatchType = .argumentValue( + name: displayNames, matchedText: value) bestSnippet = "possible value: \(value)" matchFound = true break @@ -318,20 +325,23 @@ struct SearchEngine { // 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) + 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 - )) + results.append( + SearchResult( + commandPath: commandPath, + matchType: matchType, + contextSnippet: bestSnippet + )) } } } @@ -342,7 +352,8 @@ struct SearchEngine { /// - 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 { + private func extractSnippet(from text: String, around term: String) -> String + { let maxSnippetLength = 80 let lowercasedText = text.lowercased() @@ -351,15 +362,22 @@ struct SearchEngine { return String(text.prefix(maxSnippetLength)) } - let matchIndex = lowercasedText.distance(from: lowercasedText.startIndex, to: matchRange.lowerBound) + 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 + 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." + 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" + 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 } @@ -409,13 +429,17 @@ struct SearchEngine { // Display command matches if !commandResults.isEmpty { output += "\nCOMMANDS:\n" - output += formatCommandResults(commandResults, term: term, screenWidth: screenWidth, useHighlighting: useHighlighting) + 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 += formatArgumentResults( + argumentResults, term: term, screenWidth: screenWidth, + useHighlighting: useHighlighting) } output += "\nUse '\(toolName) --help' for detailed information." @@ -438,20 +462,25 @@ struct SearchEngine { 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) + 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) + 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) + 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: @@ -486,18 +515,24 @@ struct SearchEngine { // 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) + 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) + 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) + let highlightedSnippet = ANSICode.highlightMatches( + in: result.contextSnippet, matching: term, enabled: useHighlighting) output += " \(name) (\(highlightedSnippet))\n" default: diff --git a/Tests/ArgumentParserUnitTests/SearchEngineTests.swift b/Tests/ArgumentParserUnitTests/SearchEngineTests.swift index f50e9894e..44cdfdaea 100644 --- a/Tests/ArgumentParserUnitTests/SearchEngineTests.swift +++ b/Tests/ArgumentParserUnitTests/SearchEngineTests.swift @@ -10,6 +10,7 @@ //===----------------------------------------------------------------------===// import XCTest + @testable import ArgumentParser final class SearchEngineTests: XCTestCase {} @@ -94,10 +95,11 @@ extension SearchEngineTests { XCTAssertTrue(results.allSatisfy { $0.isCommandMatch }) // Both should be command name matches - XCTAssertTrue(results.allSatisfy { - if case .commandName = $0.matchType { return true } - return false - }) + XCTAssertTrue( + results.allSatisfy { + if case .commandName = $0.matchType { return true } + return false + }) } func testSearch_CommandAlias() { @@ -236,10 +238,12 @@ extension SearchEngineTests { 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." - )) + @Option( + help: ArgumentHelp( + "Short help", + discussion: + "This is a much longer discussion that explains the configuration file format in detail." + )) var config: String? } @@ -260,7 +264,8 @@ extension SearchEngineTests { } XCTAssertGreaterThan(discussionMatches.count, 0) // Should match in discussion, not in the short help - XCTAssertTrue(results[0].contextSnippet.contains("configuration file format")) + XCTAssertTrue( + results[0].contextSnippet.contains("configuration file format")) } func testSearch_ArgumentDefaultValue() { @@ -284,7 +289,9 @@ extension SearchEngineTests { if case .argumentValue = $0.matchType { return true } return false } - XCTAssertGreaterThan(valueMatches.count, 0, "Should find 'json' in default value or possible values") + XCTAssertGreaterThan( + valueMatches.count, 0, + "Should find 'json' in default value or possible values") } func testSearch_StringDefaultValue() { @@ -305,7 +312,8 @@ extension SearchEngineTests { // 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") + XCTAssertGreaterThanOrEqual( + results.count, 1, "Should find match in default value") // Should find match as argumentValue let valueMatches = results.filter { @@ -315,13 +323,17 @@ extension SearchEngineTests { } return false } - XCTAssertGreaterThan(valueMatches.count, 0, "Should find 'myapp' in the default value '/var/log/myapp'") + 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") + XCTAssertTrue( + hasDefaultSnippet, + "Context snippet should indicate this is a default value") } func testSearch_PossibleValues_Explicit() { @@ -336,13 +348,15 @@ extension SearchEngineTests { // Test each enum value for searchTerm in ["json", "xml", "yaml"] { let results = engine.search(for: searchTerm) - XCTAssertGreaterThanOrEqual(results.count, 1, "Should find '\(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") + XCTAssertGreaterThan( + valueMatches.count, 0, "'\(searchTerm)' should match as a value") } } } @@ -454,8 +468,10 @@ extension SearchEngineTests { 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") + 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 { @@ -470,13 +486,17 @@ extension SearchEngineTests { extension SearchEngineTests { 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") + 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) + let highlighted = ANSICode.highlightMatches( + in: text, matching: "test", enabled: false) XCTAssertEqual(highlighted, text) XCTAssertFalse(highlighted.contains(ANSICode.bold)) @@ -484,7 +504,8 @@ extension SearchEngineTests { func testANSI_HighlightMultipleMatches() { let text = "test this test that test" - let highlighted = ANSICode.highlightMatches(in: text, matching: "test", enabled: true) + let highlighted = ANSICode.highlightMatches( + in: text, matching: "test", enabled: true) // Should highlight all three occurrences let boldCount = highlighted.components(separatedBy: ANSICode.bold).count - 1 @@ -493,7 +514,8 @@ extension SearchEngineTests { func testANSI_HighlightCaseInsensitive() { let text = "Test this TEST that TeSt" - let highlighted = ANSICode.highlightMatches(in: text, matching: "test", enabled: true) + 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 @@ -507,7 +529,8 @@ extension SearchEngineTests { 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." + discussion: + "This is a very long discussion that contains many words and the word needle appears somewhere in the middle of all this text." ) } @@ -590,7 +613,8 @@ extension SearchEngineTests { func testFormatResults_CommandDescriptionFormatting() { struct TestCommand: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "This is a test command that performs various operations on data files." + abstract: + "This is a test command that performs various operations on data files." ) } @@ -618,13 +642,15 @@ extension SearchEngineTests { // 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")) + 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." + 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." ) } @@ -653,15 +679,19 @@ extension SearchEngineTests { // 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 + line.starts(with: " ") // 6 spaces for continuation lines } - XCTAssertTrue(hasIndentedLine, "Should have wrapped lines with proper indentation") + 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") + @Option( + help: + "This option controls the maximum retry attempts for network requests" + ) var maxRetries: Int = 3 } @@ -695,10 +725,12 @@ extension SearchEngineTests { 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." - )) + @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? } @@ -727,9 +759,10 @@ extension SearchEngineTests { // 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 + line.starts(with: " ") // 6 spaces for continuation } - XCTAssertTrue(hasIndentedLine, "Argument description should wrap with 6-space indent") + XCTAssertTrue( + hasIndentedLine, "Argument description should wrap with 6-space indent") } func testFormatResults_ArgumentValueFormatting() { From b39256d88dc1c1e856d3c122a8ad7a4878100153 Mon Sep 17 00:00:00 2001 From: Matt Dickoff Date: Fri, 21 Nov 2025 14:08:56 -0800 Subject: [PATCH 11/17] Rename to CommandSearcher --- ...archEngine.swift => CommandSearcher.swift} | 2 +- .../ArgumentParser/Usage/HelpCommand.swift | 6 +- ...Tests.swift => CommandSearcherTests.swift} | 90 +++++++++---------- 3 files changed, 49 insertions(+), 49 deletions(-) rename Sources/ArgumentParser/Usage/{SearchEngine.swift => CommandSearcher.swift} (99%) rename Tests/ArgumentParserUnitTests/{SearchEngineTests.swift => CommandSearcherTests.swift} (93%) diff --git a/Sources/ArgumentParser/Usage/SearchEngine.swift b/Sources/ArgumentParser/Usage/CommandSearcher.swift similarity index 99% rename from Sources/ArgumentParser/Usage/SearchEngine.swift rename to Sources/ArgumentParser/Usage/CommandSearcher.swift index 6dbfd07fe..7aa548ed3 100644 --- a/Sources/ArgumentParser/Usage/SearchEngine.swift +++ b/Sources/ArgumentParser/Usage/CommandSearcher.swift @@ -114,7 +114,7 @@ struct SearchResult { } /// Engine for searching through command trees. -struct SearchEngine { +struct CommandSearcher { /// The starting point for the search (root or subcommand). var rootNode: Tree diff --git a/Sources/ArgumentParser/Usage/HelpCommand.swift b/Sources/ArgumentParser/Usage/HelpCommand.swift index 736bd6693..1adda3910 100644 --- a/Sources/ArgumentParser/Usage/HelpCommand.swift +++ b/Sources/ArgumentParser/Usage/HelpCommand.swift @@ -83,16 +83,16 @@ struct HelpCommand: ParsableCommand { } // Create search engine and perform search - let searchEngine = SearchEngine( + let commandSearcher = CommandSearcher( rootNode: tree, commandStack: commandStack.isEmpty ? [tree.element] : commandStack, visibility: visibility ) - let results = searchEngine.search(for: term) + let results = commandSearcher.search(for: term) // Format and print results - let output = SearchEngine.formatResults( + let output = CommandSearcher.formatResults( results, term: term, toolName: toolName, diff --git a/Tests/ArgumentParserUnitTests/SearchEngineTests.swift b/Tests/ArgumentParserUnitTests/CommandSearcherTests.swift similarity index 93% rename from Tests/ArgumentParserUnitTests/SearchEngineTests.swift rename to Tests/ArgumentParserUnitTests/CommandSearcherTests.swift index 44cdfdaea..c0f5fe0f8 100644 --- a/Tests/ArgumentParserUnitTests/SearchEngineTests.swift +++ b/Tests/ArgumentParserUnitTests/CommandSearcherTests.swift @@ -13,7 +13,7 @@ import XCTest @testable import ArgumentParser -final class SearchEngineTests: XCTestCase {} +final class CommandSearcherTests: XCTestCase {} // MARK: - Test Commands @@ -79,10 +79,10 @@ private struct CommandWithEnums: ParsableCommand { // MARK: - Basic Search Tests -extension SearchEngineTests { +extension CommandSearcherTests { func testSearch_CommandName() { let tree = CommandParser(ParentCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [ParentCommand.self], visibility: .default @@ -104,7 +104,7 @@ extension SearchEngineTests { func testSearch_CommandAlias() { let tree = CommandParser(ParentCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [ParentCommand.self], visibility: .default @@ -122,7 +122,7 @@ extension SearchEngineTests { func testSearch_CommandAbstract() { let tree = CommandParser(SimpleCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [SimpleCommand.self], visibility: .default @@ -139,7 +139,7 @@ extension SearchEngineTests { func testSearch_CommandDiscussion() { let tree = CommandParser(SimpleCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [SimpleCommand.self], visibility: .default @@ -157,10 +157,10 @@ extension SearchEngineTests { // MARK: - Argument Search Tests -extension SearchEngineTests { +extension CommandSearcherTests { func testSearch_ArgumentName() { let tree = CommandParser(SimpleCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [SimpleCommand.self], visibility: .default @@ -179,7 +179,7 @@ extension SearchEngineTests { func testSearch_ArgumentHelp() { let tree = CommandParser(SimpleCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [SimpleCommand.self], visibility: .default @@ -198,7 +198,7 @@ extension SearchEngineTests { func testSearch_ArgumentValue() { let tree = CommandParser(CommandWithEnums.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [CommandWithEnums.self], visibility: .default @@ -217,7 +217,7 @@ extension SearchEngineTests { func testSearch_PositionalArgument() { let tree = CommandParser(SimpleCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [SimpleCommand.self], visibility: .default @@ -248,7 +248,7 @@ extension SearchEngineTests { } let tree = CommandParser(TestCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [TestCommand.self], visibility: .default @@ -275,7 +275,7 @@ extension SearchEngineTests { } let tree = CommandParser(TestCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [TestCommand.self], visibility: .default @@ -303,7 +303,7 @@ extension SearchEngineTests { } let tree = CommandParser(TestCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [TestCommand.self], visibility: .default @@ -339,7 +339,7 @@ extension SearchEngineTests { func testSearch_PossibleValues_Explicit() { // Test that all possible enum values are searchable let tree = CommandParser(CommandWithEnums.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [CommandWithEnums.self], visibility: .default @@ -363,10 +363,10 @@ extension SearchEngineTests { // MARK: - Case Sensitivity Tests -extension SearchEngineTests { +extension CommandSearcherTests { func testSearch_CaseInsensitive() { let tree = CommandParser(SimpleCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [SimpleCommand.self], visibility: .default @@ -384,10 +384,10 @@ extension SearchEngineTests { // MARK: - Result Ordering Tests -extension SearchEngineTests { +extension CommandSearcherTests { func testSearch_ResultOrdering() { let tree = CommandParser(ParentCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [ParentCommand.self], visibility: .default @@ -410,10 +410,10 @@ extension SearchEngineTests { // MARK: - Empty and No-Match Tests -extension SearchEngineTests { +extension CommandSearcherTests { func testSearch_EmptyTerm() { let tree = CommandParser(SimpleCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [SimpleCommand.self], visibility: .default @@ -426,7 +426,7 @@ extension SearchEngineTests { func testSearch_NoMatches() { let tree = CommandParser(SimpleCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [SimpleCommand.self], visibility: .default @@ -440,7 +440,7 @@ extension SearchEngineTests { // MARK: - Priority Tests -extension SearchEngineTests { +extension CommandSearcherTests { func testSearch_MatchPriority() { // When a term matches multiple attributes of the same item, // only the highest priority match should be returned @@ -455,7 +455,7 @@ extension SearchEngineTests { } let tree = CommandParser(TestCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [TestCommand.self], visibility: .default @@ -483,7 +483,7 @@ extension SearchEngineTests { // MARK: - ANSI Highlighting Tests -extension SearchEngineTests { +extension CommandSearcherTests { func testANSI_Highlight() { let text = "This is a test string" let highlighted = ANSICode.highlightMatches( @@ -525,7 +525,7 @@ extension SearchEngineTests { // MARK: - Snippet Extraction Tests -extension SearchEngineTests { +extension CommandSearcherTests { func testSnippet_CenteredOnMatch() { struct TestCommand: ParsableCommand { static let configuration = CommandConfiguration( @@ -535,7 +535,7 @@ extension SearchEngineTests { } let tree = CommandParser(TestCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [TestCommand.self], visibility: .default @@ -553,9 +553,9 @@ extension SearchEngineTests { // MARK: - Format Results Tests -extension SearchEngineTests { +extension CommandSearcherTests { func testFormatResults_NoMatches() { - let formatted = SearchEngine.formatResults( + let formatted = CommandSearcher.formatResults( [], term: "test", toolName: "mytool", @@ -570,14 +570,14 @@ extension SearchEngineTests { func testFormatResults_WithMatches() { let tree = CommandParser(SimpleCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [SimpleCommand.self], visibility: .default ) let results = engine.search(for: "name") - let formatted = SearchEngine.formatResults( + let formatted = CommandSearcher.formatResults( results, term: "name", toolName: "simple-command", @@ -591,14 +591,14 @@ extension SearchEngineTests { func testFormatResults_GroupsByType() { let tree = CommandParser(ParentCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [ParentCommand.self], visibility: .default ) let results = engine.search(for: "child") - let formatted = SearchEngine.formatResults( + let formatted = CommandSearcher.formatResults( results, term: "child", toolName: "parent-command", @@ -619,14 +619,14 @@ extension SearchEngineTests { } let tree = CommandParser(TestCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [TestCommand.self], visibility: .default ) let results = engine.search(for: "operations") - let formatted = SearchEngine.formatResults( + let formatted = CommandSearcher.formatResults( results, term: "operations", toolName: "test-command", @@ -655,14 +655,14 @@ extension SearchEngineTests { } let tree = CommandParser(TestCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [TestCommand.self], visibility: .default ) let results = engine.search(for: "screen width") - let formatted = SearchEngine.formatResults( + let formatted = CommandSearcher.formatResults( results, term: "screen width", toolName: "test-command", @@ -696,14 +696,14 @@ extension SearchEngineTests { } let tree = CommandParser(TestCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [TestCommand.self], visibility: .default ) let results = engine.search(for: "network requests") - let formatted = SearchEngine.formatResults( + let formatted = CommandSearcher.formatResults( results, term: "network requests", toolName: "test-command", @@ -735,14 +735,14 @@ extension SearchEngineTests { } let tree = CommandParser(TestCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [TestCommand.self], visibility: .default ) let results = engine.search(for: "screen width") - let formatted = SearchEngine.formatResults( + let formatted = CommandSearcher.formatResults( results, term: "screen width", toolName: "test-command", @@ -773,14 +773,14 @@ extension SearchEngineTests { } let tree = CommandParser(TestCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [TestCommand.self], visibility: .default ) let results = engine.search(for: "yaml") - let formatted = SearchEngine.formatResults( + let formatted = CommandSearcher.formatResults( results, term: "yaml", toolName: "test-command", @@ -807,14 +807,14 @@ extension SearchEngineTests { } let tree = CommandParser(TestCommand.self).commandTree - let engine = SearchEngine( + let engine = CommandSearcher( rootNode: tree, commandStack: [TestCommand.self], visibility: .default ) let results = engine.search(for: "app.log") - let formatted = SearchEngine.formatResults( + let formatted = CommandSearcher.formatResults( results, term: "app.log", toolName: "test-command", From 933d5397371da473efee4e9ef51f41842be4c9b5 Mon Sep 17 00:00:00 2001 From: Matt Dickoff Date: Fri, 21 Nov 2025 14:55:19 -0800 Subject: [PATCH 12/17] add new file to CmakeLists.txt --- Sources/ArgumentParser/CMakeLists.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From bda79bd4b3c2e7cb9452c3d1401d29b35c542944 Mon Sep 17 00:00:00 2001 From: Matt Dickoff Date: Fri, 21 Nov 2025 17:33:37 -0800 Subject: [PATCH 13/17] attempt to fix build errors for other platforms --- Sources/ArgumentParser/Usage/CommandSearcher.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/ArgumentParser/Usage/CommandSearcher.swift b/Sources/ArgumentParser/Usage/CommandSearcher.swift index 7aa548ed3..6253c20f0 100644 --- a/Sources/ArgumentParser/Usage/CommandSearcher.swift +++ b/Sources/ArgumentParser/Usage/CommandSearcher.swift @@ -50,12 +50,12 @@ enum ANSICode { while searchStartIndex < text.endIndex { let searchRange = searchStartIndex.. Date: Fri, 21 Nov 2025 19:46:04 -0800 Subject: [PATCH 14/17] more cross pltaform fixes --- .../Usage/CommandSearcher.swift | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/Sources/ArgumentParser/Usage/CommandSearcher.swift b/Sources/ArgumentParser/Usage/CommandSearcher.swift index 6253c20f0..b7e62b8be 100644 --- a/Sources/ArgumentParser/Usage/CommandSearcher.swift +++ b/Sources/ArgumentParser/Usage/CommandSearcher.swift @@ -23,6 +23,29 @@ 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.. Date: Fri, 21 Nov 2025 19:51:57 -0800 Subject: [PATCH 15/17] move String extensions to shared location now that we're using it in multiple spots --- .../Completions/CompletionsGenerator.swift | 45 ------------------- .../Utilities/StringExtensions.swift | 45 +++++++++++++++++++ 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/Sources/ArgumentParser/Completions/CompletionsGenerator.swift b/Sources/ArgumentParser/Completions/CompletionsGenerator.swift index 264a2d418..feb5b667d 100644 --- a/Sources/ArgumentParser/Completions/CompletionsGenerator.swift +++ b/Sources/ArgumentParser/Completions/CompletionsGenerator.swift @@ -175,51 +175,6 @@ extension String { return result } - - 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 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/Utilities/StringExtensions.swift b/Sources/ArgumentParser/Utilities/StringExtensions.swift index 0e2076727..be0700864 100644 --- a/Sources/ArgumentParser/Utilities/StringExtensions.swift +++ b/Sources/ArgumentParser/Utilities/StringExtensions.swift @@ -255,4 +255,49 @@ 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) + } + } } From e0104063b517bd07cc058a3a85c6fd302ed20140 Mon Sep 17 00:00:00 2001 From: Matt Dickoff Date: Fri, 21 Nov 2025 20:12:23 -0800 Subject: [PATCH 16/17] more foundation removal, use existing code in the repo --- .../Completions/CompletionsGenerator.swift | 23 ---------------- .../Usage/CommandSearcher.swift | 25 +++++++++++------ .../Utilities/StringExtensions.swift | 27 +++++++++++++++++++ 3 files changed, 44 insertions(+), 31 deletions(-) diff --git a/Sources/ArgumentParser/Completions/CompletionsGenerator.swift b/Sources/ArgumentParser/Completions/CompletionsGenerator.swift index feb5b667d..64de9a8f8 100644 --- a/Sources/ArgumentParser/Completions/CompletionsGenerator.swift +++ b/Sources/ArgumentParser/Completions/CompletionsGenerator.swift @@ -152,29 +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.. Range? { + 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 { + guard + let match = lowercased.firstMatch( + of: lowercasedSubstring, at: lowercased.startIndex) + else { return nil } @@ -75,10 +79,17 @@ enum ANSICode { let searchRange = searchStartIndex.. 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.. Date: Fri, 21 Nov 2025 20:34:32 -0800 Subject: [PATCH 17/17] Add swift format ignores for the test names --- .../CommandSearcherTests.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Tests/ArgumentParserUnitTests/CommandSearcherTests.swift b/Tests/ArgumentParserUnitTests/CommandSearcherTests.swift index c0f5fe0f8..5e41fa1af 100644 --- a/Tests/ArgumentParserUnitTests/CommandSearcherTests.swift +++ b/Tests/ArgumentParserUnitTests/CommandSearcherTests.swift @@ -78,8 +78,9 @@ private struct CommandWithEnums: ParsableCommand { } // 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( @@ -157,6 +158,7 @@ extension CommandSearcherTests { // MARK: - Argument Search Tests +// swift-format-ignore: AlwaysUseLowerCamelCase extension CommandSearcherTests { func testSearch_ArgumentName() { let tree = CommandParser(SimpleCommand.self).commandTree @@ -363,6 +365,7 @@ extension CommandSearcherTests { // MARK: - Case Sensitivity Tests +// swift-format-ignore: AlwaysUseLowerCamelCase extension CommandSearcherTests { func testSearch_CaseInsensitive() { let tree = CommandParser(SimpleCommand.self).commandTree @@ -384,6 +387,7 @@ extension CommandSearcherTests { // MARK: - Result Ordering Tests +// swift-format-ignore: AlwaysUseLowerCamelCase extension CommandSearcherTests { func testSearch_ResultOrdering() { let tree = CommandParser(ParentCommand.self).commandTree @@ -410,6 +414,7 @@ extension CommandSearcherTests { // MARK: - Empty and No-Match Tests +// swift-format-ignore: AlwaysUseLowerCamelCase extension CommandSearcherTests { func testSearch_EmptyTerm() { let tree = CommandParser(SimpleCommand.self).commandTree @@ -440,6 +445,7 @@ extension CommandSearcherTests { // MARK: - Priority Tests +// swift-format-ignore: AlwaysUseLowerCamelCase extension CommandSearcherTests { func testSearch_MatchPriority() { // When a term matches multiple attributes of the same item, @@ -483,6 +489,7 @@ extension CommandSearcherTests { // MARK: - ANSI Highlighting Tests +// swift-format-ignore: AlwaysUseLowerCamelCase extension CommandSearcherTests { func testANSI_Highlight() { let text = "This is a test string" @@ -525,6 +532,7 @@ extension CommandSearcherTests { // MARK: - Snippet Extraction Tests +// swift-format-ignore: AlwaysUseLowerCamelCase extension CommandSearcherTests { func testSnippet_CenteredOnMatch() { struct TestCommand: ParsableCommand { @@ -553,6 +561,7 @@ extension CommandSearcherTests { // MARK: - Format Results Tests +// swift-format-ignore: AlwaysUseLowerCamelCase extension CommandSearcherTests { func testFormatResults_NoMatches() { let formatted = CommandSearcher.formatResults(