import ArgumentParser import CliDoc import Rainbow extension CommandConfiguration { static func create( commandName: String, abstract: String, usesExtraArgs: Bool = true, discussion: Discussion ) -> Self { .init( commandName: commandName, abstract: createAbstract(abstract), discussion: discussion.render() ) } static func create( commandName: String, abstract: String, usesExtraArgs: Bool = true, discussion nodes: [Node] ) -> Self { .init( commandName: commandName, abstract: createAbstract(abstract), discussion: Discussion(nodes: nodes, usesExtraArgs: usesExtraArgs).render() ) } static func playbook2( commandName: String, abstract: String, parentCommand: String? = nil, examples: (label: String, example: String)... ) -> Self { .init( commandName: commandName, abstract: Abstract { abstract.blue }, usage: """ \(Constants.appName)\(parentCommand != nil ? " \(parentCommand!)" : "") \(commandName) """.blue.bold.italic + " [OPTIONS]".green + " [ARGUMENTS]".cyan + " --" + " [EXTRA-OPTIONS...]".magenta, discussion: CliDoc.Discussion.playbook(examples: examples) ) } static func playbook( commandName: String, abstract: String, parentCommand: String? = nil, examples: (label: String, example: String)... ) -> Self { .create( commandName: commandName, abstract: abstract, discussion: .default( usesExtraArgs: true, parentCommand: parentCommand, examples: examples, seeAlso: .seeAlso(label: "Ansible playbook options.", command: "ansible-playbook") ) ) } } func createAbstract(_ string: String) -> String { "\(string.blue)" } // TODO: Remove struct Discussion { let nodes: [Node] static func `default`( usesExtraArgs: Bool, parentCommand: String?, examples: [(label: String, example: String)], seeAlso: Node? ) -> Self { var nodes = Array.defaultNodes + examples.nodes(parentCommand) if let seeAlso { nodes.append(seeAlso) } if usesExtraArgs && examples.count > 0 { nodes.append(.important(label: Constants.importantExtraArgsNote)) } return .init( nodes: nodes, usesExtraArgs: usesExtraArgs ) } init(usesExtraArgs: Bool = true, _ nodes: Node...) { self.init(nodes: nodes, usesExtraArgs: usesExtraArgs) } init(nodes: [Node], usesExtraArgs: Bool = true) { var nodes = nodes if let firstExampleIndex = nodes.firstIndex(where: \.isExampleNode) { nodes.insert(.exampleHeading, at: firstExampleIndex) } if usesExtraArgs { if let lastExampleIndex = nodes.lastIndex(where: \.isExampleNode) { guard case let .example(.container(exampleNodes, _)) = nodes[lastExampleIndex] else { // this should never happen. print(nodes[lastExampleIndex]) fatalError() } // Get the last element of the example container, which will be the // text for the example. guard let exampleNode = exampleNodes.last else { print(exampleNodes) fatalError() } // replace the first element, which is the header so that we can // replace it with a new header. nodes.insert( .example(.exampleContainer( "Passing extra args.", "\(exampleNode.render().replacingOccurrences(of: " $ ", with: ""))" + " -- --vault-id \"myId@$SCRIPTS/vault-gopass-client\"" )), at: nodes.index(after: lastExampleIndex) ) } } self.nodes = nodes } func render() -> String { nodes.render() } } enum Node { struct Separator { let string: String let count: Int init(_ string: String, repeating count: Int = 1) { self.string = string self.count = count } var value: String { string.repeating(count) } static var empty: Self { .init("") } static func space(_ count: Int = 1) -> Self { .init(" ", repeating: count) } static func newLine(_ count: Int = 1) -> Self { .init("\n", repeating: count) } static func tab(_ count: Int) -> Self { .init("\t", repeating: count) } } /// A container that holds onto other nodes to be rendered. indirect case container(_ nodes: [Node], separator: Separator = .newLine()) /// A container to identify example nodes, so some other nodes can get injected /// when this is rendered. /// /// NOTE: Things blow up if this is not used correctly, at least when using the `Discussion` /// to render the nodes, so this is not safe to create aside from the static methods that /// create an example node. If not using in the context of a `Discussion` then it's fine to /// use, as it doesn't do anything except stand as an type identifier. indirect case example(_ node: Node) /// A root node that renders the given string without modification. case text(_ text: String) static func container(separator: Separator = .newLine(), _ nodes: Node...) -> Self { .container(nodes, separator: separator) } static func container(_ lhs: Node, _ rhs: Node, separator: Separator = .newLine()) -> Self { .container([lhs, rhs], separator: separator) } static func styledText(_ text: String, color: NamedColor? = nil, styles: [Style]? = nil) -> Self { var string = text if let color { string = string.applyingColor(color) } if let styles { for style in styles { string = string.applyingStyle(style) } } return .text(string) } static func styledText(_ text: String, color: NamedColor? = nil, style: Style) -> Self { .styledText(text, color: color, styles: [style]) } static func boldYellowText(_ text: String) -> Self { .styledText(text, color: .yellow, styles: [.bold]) } static func section(header: Node, content: Node, separator: Separator = .newLine()) -> Self { .container([header, content], separator: separator) } static func section(header: String, label: Node, separator: Separator = .newLine()) -> Self { .section(header: .boldYellowText(header), content: label, separator: separator) } static func important(label: String) -> Self { .section( header: .text("IMPORTANT NOTE:".red.bold.underline), content: .text(label.italic), separator: .newLine() ) } static func note(_ text: String, inline: Bool = true) -> Self { .section(header: "NOTE:", label: .text(text.italic), separator: inline ? .space() : .newLine()) } static func shellCommand(_ text: String, symbol: String = " $") -> Self { .text("\(symbol) \(text)") } static func seeAlso(label: String, command: String, appendHelpToCommand: Bool = true) -> Self { .container([ .container(.boldYellowText("See Also:"), .text(label.italic), separator: .space()), .shellCommand("\(appendHelpToCommand ? command + " --help" : command)") ]) } static var exampleHeading: Self { .section(header: "Examples:", label: .text("Some common examples."), separator: .space()) } var isExampleNode: Bool { if case .example = self { return true } return false } fileprivate static func exampleContainer(_ label: String, _ command: String) -> Self { .container( .exampleLabel("\(label)\n"), .shellCommand(command, symbol: " $"), separator: .empty ) } /// Renders an example usage of the command. static func example(label: String, example: String, parentCommand: String?) -> Self { let string: String if let parent = parentCommand { string = "\(Constants.appName) \(parent) \(example)" } else { string = "\(Constants.appName) \(example)" } return .example(.exampleContainer(label, string)) } static func exampleLabel(_ label: String) -> Node { .text(" \(label.green.italic)") } func render() -> String { switch self { case let .container(nodes, separator): return nodes.render(separator: separator) case let .text(text): return text case let .example(node): return node.render() } } } extension Node: ExpressibleByStringLiteral { typealias StringLiteralType = String init(stringLiteral value: String) { self = .text(value) } } extension Node: ExpressibleByArrayLiteral { typealias ArrayLiteralElement = Node init(arrayLiteral elements: Node...) { self = .container(elements) } } private extension Array where Element == (label: String, example: String) { func nodes(_ parentCommand: String?) -> [Node] { map { .example(label: $0.label, example: $0.example, parentCommand: parentCommand) } } } extension Array where Element == Node { static var defaultNodes: Self { [.note("Most options are not required if you have a configuration file setup.")] } fileprivate func render(separator: Node.Separator = .newLine()) -> String { map { // Strip of any new-line characters from the last section of the rendered string // of the node. This allows us to have a consistent single new-line between each // rendered node. var string = $0.render()[...] while string.hasSuffix("\n") { string = string.dropLast() } return String(string) } .joined(separator: separator.value) } } private extension String { func repeating(_ count: Int) -> Self { guard count > 0 else { return self } var count = count var output = self while count > 0 { output = "\(output)\(self)" count -= 1 } return output } }