HelpGenerator.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  1. //===----------------------------------------------------------------------===//
  2. //
  3. // This source file is part of the Swift Argument Parser open source project
  4. //
  5. // Copyright (c) 2020 Apple Inc. and the Swift project authors
  6. // Licensed under Apache License v2.0 with Runtime Library Exception
  7. //
  8. // See https://swift.org/LICENSE.txt for license information
  9. //
  10. //===----------------------------------------------------------------------===//
  11. internal struct HelpGenerator {
  12. static let helpIndent = 2
  13. static let labelColumnWidth = 26
  14. static var systemScreenWidth: Int { Platform.terminalWidth }
  15. struct Section {
  16. struct Element: Hashable {
  17. var label: String
  18. var abstract: String = ""
  19. var discussion: ArgumentDiscussion?
  20. var paddedLabel: String {
  21. String(repeating: " ", count: HelpGenerator.helpIndent) + label
  22. }
  23. func rendered(screenWidth: Int) -> String {
  24. let paddedLabel = self.paddedLabel
  25. let wrappedAbstract = self.abstract
  26. .wrapped(
  27. to: screenWidth, wrappingIndent: HelpGenerator.labelColumnWidth)
  28. var wrappedDiscussion = ""
  29. if case .staticText(let discussionText) = discussion {
  30. wrappedDiscussion =
  31. discussionText.isEmpty
  32. ? ""
  33. : discussionText.wrapped(
  34. to: screenWidth, wrappingIndent: HelpGenerator.helpIndent * 4)
  35. + "\n"
  36. } else if case .enumerated(let preamble, let options) = discussion {
  37. var formattedHelp: String = ""
  38. let discussionIndentFactor = 4
  39. // If there is a preamble, append this to the formatted text
  40. if let preamble {
  41. formattedHelp +=
  42. preamble.wrapped(
  43. to: screenWidth,
  44. wrappingIndent: HelpGenerator.helpIndent
  45. * discussionIndentFactor) + "\n"
  46. }
  47. // Padded label
  48. for opt in options.allValueStrings {
  49. let description = options.allValueDescriptions[opt] ?? ""
  50. let paddedOptionLabel =
  51. String(
  52. repeating: " ",
  53. count: HelpGenerator.helpIndent * discussionIndentFactor) + opt
  54. // Adds a hyphen (`-`) to the beginning of each value description,
  55. // without it affecting the proper indentation level.
  56. let hyphen = "- "
  57. let wrappedHelp = String(
  58. (hyphen + description)
  59. .wrapped(
  60. to: screenWidth,
  61. wrappingIndent: HelpGenerator.labelColumnWidth + 2)
  62. )
  63. var whitespaceToDrop = hyphen.count
  64. let renderedHelp: String = {
  65. if paddedOptionLabel.count < HelpGenerator.labelColumnWidth {
  66. // Render after the padded label.
  67. whitespaceToDrop += paddedOptionLabel.count
  68. return String(
  69. paddedOptionLabel + wrappedHelp.dropFirst(whitespaceToDrop))
  70. } else {
  71. // Render in a new line.
  72. return paddedOptionLabel + "\n"
  73. + wrappedHelp.dropFirst(whitespaceToDrop)
  74. }
  75. }()
  76. formattedHelp += renderedHelp + "\n"
  77. }
  78. wrappedDiscussion = formattedHelp
  79. }
  80. let renderedAbstract: String = {
  81. guard !abstract.isEmpty else { return "" }
  82. if paddedLabel.count < HelpGenerator.labelColumnWidth {
  83. // Render after padded label.
  84. return String(wrappedAbstract.dropFirst(paddedLabel.count))
  85. } else {
  86. // Render in a new line.
  87. return "\n" + wrappedAbstract
  88. }
  89. }()
  90. return paddedLabel
  91. + renderedAbstract + "\n"
  92. + wrappedDiscussion
  93. }
  94. }
  95. enum Header: CustomStringConvertible, Equatable {
  96. case positionalArguments
  97. case subcommands
  98. case options
  99. case title(String)
  100. case groupedSubcommands(String)
  101. var description: String {
  102. switch self {
  103. case .positionalArguments:
  104. return "Arguments"
  105. case .subcommands:
  106. return "Subcommands"
  107. case .options:
  108. return "Options"
  109. case .title(let name):
  110. return name
  111. case .groupedSubcommands(let name):
  112. return "\(name) Subcommands"
  113. }
  114. }
  115. }
  116. var header: Header
  117. var elements: [Element]
  118. var isSubcommands: Bool = false
  119. func rendered(screenWidth: Int) -> String {
  120. guard !elements.isEmpty else { return "" }
  121. let renderedElements = elements.map {
  122. $0.rendered(screenWidth: screenWidth)
  123. }.joined()
  124. return "\(String(describing: header).uppercased()):\n"
  125. + renderedElements
  126. }
  127. }
  128. struct DiscussionSection {
  129. var title: String = ""
  130. var content: String
  131. }
  132. var commandStack: [ParsableCommand.Type]
  133. var abstract: String
  134. var usage: String
  135. var sections: [Section]
  136. init(commandStack: [ParsableCommand.Type], visibility: ArgumentVisibility) {
  137. guard let root = commandStack.first, let currentCommand = commandStack.last
  138. else { fatalError() }
  139. let currentArgSet = ArgumentSet(
  140. currentCommand, visibility: visibility, parent: nil)
  141. self.commandStack = commandStack
  142. // Build the tool name and subcommand name from the command configuration
  143. var toolName = commandStack.map { $0._commandName }.joined(separator: " ")
  144. if let superName = root.configuration._superCommandName {
  145. toolName = "\(superName) \(toolName)"
  146. }
  147. if let usage = currentCommand.configuration.usage {
  148. self.usage = usage
  149. } else {
  150. var usage = UsageGenerator(
  151. toolName: toolName, definition: [currentArgSet]
  152. )
  153. .synopsis
  154. if !currentCommand.configuration.subcommands.isEmpty {
  155. if usage.last != " " { usage += " " }
  156. usage += "<subcommand>"
  157. }
  158. self.usage = usage
  159. }
  160. self.abstract = currentCommand.configuration.abstract
  161. if !currentCommand.configuration.discussion.isEmpty {
  162. if !self.abstract.isEmpty {
  163. self.abstract += "\n"
  164. }
  165. self.abstract += "\n\(currentCommand.configuration.discussion)"
  166. }
  167. self.sections = HelpGenerator.generateSections(
  168. commandStack: commandStack, visibility: visibility)
  169. }
  170. init(_ type: ParsableArguments.Type, visibility: ArgumentVisibility) {
  171. self.init(commandStack: [type.asCommand], visibility: visibility)
  172. }
  173. private static func generateSections(
  174. commandStack: [ParsableCommand.Type], visibility: ArgumentVisibility
  175. ) -> [Section] {
  176. guard !commandStack.isEmpty else { return [] }
  177. var positionalElements: [Section.Element] = []
  178. var optionElements: [Section.Element] = []
  179. // Simulate an ordered dictionary using a dictionary and array for ordering.
  180. var titledSections: [String: [Section.Element]] = [:]
  181. var sectionTitles: [String] = []
  182. /// Start with a full slice of the ArgumentSet so we can peel off one or
  183. /// more elements at a time.
  184. var args = commandStack.argumentsForHelp(visibility: visibility)[...]
  185. while let arg = args.popFirst() {
  186. assert(arg.help.visibility.isAtLeastAsVisible(as: visibility))
  187. let synopsis: String
  188. let abstract: String
  189. let allValueStrings =
  190. (arg.help.discussion?.isEnumerated ?? false)
  191. ? []
  192. : arg.help.allValueStrings.filter { !$0.isEmpty }
  193. let defaultValue = arg.help.defaultValue ?? ""
  194. let allAndDefaultValues: String
  195. switch (!allValueStrings.isEmpty, !defaultValue.isEmpty) {
  196. case (false, false):
  197. allAndDefaultValues = ""
  198. case (true, false):
  199. allAndDefaultValues =
  200. "(values: \(allValueStrings.joined(separator: ", ")))"
  201. case (false, true):
  202. allAndDefaultValues = "(default: \(defaultValue))"
  203. case (true, true):
  204. allAndDefaultValues =
  205. "(values: \(allValueStrings.joined(separator: ", ")); default: \(defaultValue))"
  206. }
  207. if arg.help.isComposite {
  208. // If this argument is composite, we have a group of arguments to
  209. // output together.
  210. let groupEnd =
  211. args.firstIndex(where: { $0.help.keys != arg.help.keys })
  212. ?? args.endIndex
  213. let groupedArgs = [arg] + args[..<groupEnd]
  214. args = args[groupEnd...]
  215. synopsis = groupedArgs
  216. .lazy
  217. .map { $0.synopsisForHelp }
  218. .joined(separator: "/")
  219. abstract =
  220. groupedArgs
  221. .lazy
  222. .map { $0.help.abstract }
  223. .first { !$0.isEmpty } ?? ""
  224. } else {
  225. synopsis = arg.synopsisForHelp
  226. abstract = arg.help.abstract
  227. }
  228. let description = [abstract, allAndDefaultValues]
  229. .lazy
  230. .filter { !$0.isEmpty }
  231. .joined(separator: " ")
  232. let element = Section.Element(
  233. label: synopsis,
  234. abstract: description,
  235. discussion: arg.help.discussion
  236. )
  237. switch (arg.kind, arg.help.parentTitle) {
  238. case (_, let sectionTitle) where !sectionTitle.isEmpty:
  239. if !titledSections.keys.contains(sectionTitle) {
  240. sectionTitles.append(sectionTitle)
  241. }
  242. titledSections[sectionTitle, default: []].append(element)
  243. case (.positional, _):
  244. positionalElements.append(element)
  245. default:
  246. optionElements.append(element)
  247. }
  248. }
  249. // swift-format-ignore: NeverForceUnwrap
  250. let configuration = commandStack.last!.configuration
  251. // Create section for a grouping of subcommands.
  252. func subcommandSection(
  253. header: Section.Header,
  254. subcommands: [ParsableCommand.Type]
  255. ) -> Section {
  256. let subcommandElements: [Section.Element] =
  257. subcommands.compactMap { command in
  258. guard command.configuration.shouldDisplay else { return nil }
  259. var label = command._commandName
  260. for alias in command.configuration.aliases {
  261. label += ", \(alias)"
  262. }
  263. if command == configuration.defaultSubcommand {
  264. label += " (default)"
  265. }
  266. return Section.Element(
  267. label: label,
  268. abstract: command.configuration.abstract)
  269. }
  270. return Section(header: header, elements: subcommandElements)
  271. }
  272. // All of the subcommand sections.
  273. var subcommands: [Section] = []
  274. // Add section for the ungrouped subcommands, if there are any.
  275. if !configuration.ungroupedSubcommands.isEmpty {
  276. subcommands.append(
  277. subcommandSection(
  278. header: .subcommands,
  279. subcommands: configuration.ungroupedSubcommands
  280. )
  281. )
  282. }
  283. // Add sections for all of the grouped subcommands.
  284. subcommands.append(
  285. contentsOf: configuration.groupedSubcommands
  286. .compactMap { group in
  287. subcommandSection(
  288. header: .groupedSubcommands(group.name),
  289. subcommands: group.subcommands
  290. )
  291. }
  292. )
  293. // Combine the compiled groups in this order:
  294. // - arguments
  295. // - named sections
  296. // - options/flags
  297. // - ungrouped subcommands
  298. // - grouped subcommands
  299. return [
  300. Section(header: .positionalArguments, elements: positionalElements)
  301. ]
  302. + sectionTitles.map { name in
  303. Section(
  304. header: .title(name), elements: titledSections[name, default: []])
  305. } + [
  306. Section(header: .options, elements: optionElements)
  307. ] + subcommands
  308. }
  309. func usageMessage() -> String {
  310. guard !usage.isEmpty else { return "" }
  311. return "Usage: \(usage.hangingIndentingEachLine(by: 7))"
  312. }
  313. var includesSubcommands: Bool {
  314. guard
  315. let subcommandSection = sections.first(where: {
  316. switch $0.header {
  317. case .groupedSubcommands, .subcommands: return true
  318. case .options, .positionalArguments, .title(_): return false
  319. }
  320. })
  321. else { return false }
  322. return !subcommandSection.elements.isEmpty
  323. }
  324. func rendered(screenWidth: Int? = nil) -> String {
  325. let screenWidth = screenWidth ?? HelpGenerator.systemScreenWidth
  326. let renderedSections =
  327. sections
  328. .map { $0.rendered(screenWidth: screenWidth) }
  329. .filter { !$0.isEmpty }
  330. .joined(separator: "\n")
  331. let renderedAbstract =
  332. abstract.isEmpty
  333. ? ""
  334. : "OVERVIEW: \(abstract)".wrapped(to: screenWidth) + "\n\n"
  335. var helpSubcommandMessage = ""
  336. if includesSubcommands {
  337. var names = commandStack.map { $0._commandName }
  338. // swift-format-ignore: NeverForceUnwrap
  339. // We must have a non-empty command stack to have gotten this far.
  340. if let superName = commandStack.first!.configuration._superCommandName {
  341. names.insert(superName, at: 0)
  342. }
  343. names.insert("help", at: 1)
  344. helpSubcommandMessage = """
  345. See '\(names.joined(separator: " ")) <subcommand>' for detailed help.
  346. """
  347. }
  348. let renderedUsage =
  349. usage.isEmpty
  350. ? ""
  351. : "USAGE: \(usage.hangingIndentingEachLine(by: 7))\n\n"
  352. return """
  353. \(renderedAbstract)\
  354. \(renderedUsage)\
  355. \(renderedSections)\(helpSubcommandMessage)
  356. """
  357. }
  358. }
  359. extension CommandConfiguration {
  360. fileprivate static var defaultHelpNames: NameSpecification {
  361. [.short, .long]
  362. }
  363. }
  364. extension NameSpecification {
  365. /// Generates a list of names for the help command at any visibility level.
  366. ///
  367. /// If the `default` visibility is used, the help names are returned
  368. /// unmodified. If a non-default visibility is used the short names are
  369. /// removed and the long names (both single and double dash) are appended with
  370. /// the name of the visibility level. After the optional name modification
  371. /// step, the name are returned in descending order.
  372. fileprivate func generateHelpNames(visibility: ArgumentVisibility) -> [Name] {
  373. self
  374. .makeNames(InputKey(name: "help", parent: nil))
  375. .compactMap { name in
  376. guard visibility.base != .default else { return name }
  377. switch name {
  378. case .long(let helpName):
  379. return .long("\(helpName)-\(visibility.base)")
  380. case .longWithSingleDash(let helpName):
  381. return .longWithSingleDash("\(helpName)-\(visibility)")
  382. case .short:
  383. // Cannot create a non-default help flag from a short name.
  384. return nil
  385. }
  386. }
  387. .sorted(by: >)
  388. }
  389. }
  390. extension BidirectionalCollection where Element == ParsableCommand.Type {
  391. /// Returns a list of help names at the requested visibility level for the
  392. /// top-most command in the command stack with custom help names.
  393. ///
  394. /// If the command stack contains no custom help names, returns the default
  395. /// help names.
  396. func getHelpNames(visibility: ArgumentVisibility) -> [Name] {
  397. self.lazy.reversed().compactMap { $0.configuration.helpNames }
  398. .first
  399. .map { $0.generateHelpNames(visibility: visibility) }
  400. ?? CommandConfiguration
  401. .defaultHelpNames
  402. .generateHelpNames(visibility: visibility)
  403. }
  404. func getPrimaryHelpName() -> Name? {
  405. getHelpNames(visibility: .default).preferredName
  406. }
  407. func versionArgumentDefinition() -> ArgumentDefinition? {
  408. guard contains(where: { !$0.configuration.version.isEmpty })
  409. else { return nil }
  410. return ArgumentDefinition(
  411. kind: .named([.long("version")]),
  412. help: .init(
  413. allValueStrings: [],
  414. options: [.isOptional],
  415. help: "Show the version.",
  416. defaultValue: nil,
  417. key: InputKey(name: "", parent: nil),
  418. isComposite: false),
  419. completion: .default,
  420. update: .nullary({ _, _, _ in })
  421. )
  422. }
  423. func helpArgumentDefinition() -> ArgumentDefinition? {
  424. let names = getHelpNames(visibility: .default)
  425. guard !names.isEmpty else { return nil }
  426. return ArgumentDefinition(
  427. kind: .named(names),
  428. help: .init(
  429. allValueStrings: [],
  430. options: [.isOptional],
  431. help: "Show help information.",
  432. defaultValue: nil,
  433. key: InputKey(name: "", parent: nil),
  434. isComposite: false),
  435. completion: .default,
  436. update: .nullary({ _, _, _ in })
  437. )
  438. }
  439. func dumpHelpArgumentDefinition() -> ArgumentDefinition {
  440. ArgumentDefinition(
  441. kind: .named([.long("experimental-dump-help")]),
  442. help: .init(
  443. allValueStrings: [],
  444. options: [.isOptional],
  445. help: ArgumentHelp("Dump help information as JSON."),
  446. defaultValue: nil,
  447. key: InputKey(name: "", parent: nil),
  448. isComposite: false),
  449. completion: .default,
  450. update: .nullary({ _, _, _ in })
  451. )
  452. }
  453. /// Returns the ArgumentSet for the last command in this stack, including
  454. /// help and version flags, when appropriate.
  455. func argumentsForHelp(visibility: ArgumentVisibility) -> ArgumentSet {
  456. guard
  457. var arguments = self.last.map({
  458. ArgumentSet($0, visibility: visibility, parent: nil)
  459. })
  460. else { return ArgumentSet() }
  461. self.versionArgumentDefinition().map { arguments.append($0) }
  462. self.helpArgumentDefinition().map { arguments.append($0) }
  463. // To add when 'dump-help' is public API:
  464. // arguments.append(self.dumpHelpArgumentDefinition())
  465. return arguments
  466. }
  467. }