import ArgumentParser import Rainbow extension CommandConfiguration { 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 playbook( commandName: String, abstract: String, parentCommand: String? = nil, examples: (label: String, example: String)... ) -> Self { .create( commandName: commandName, abstract: abstract, discussion: [.note(label: "Most options are not required if you have a configuration file setup.")] + examples.nodes(parentCommand) + [ .seeAlso(label: "Ansible playbook options.", command: "ansible-playbook"), .important(label: Constants.importantExtraArgsNote) ] ) } } func createAbstract(_ string: String) -> String { "\(string.blue)" } struct Discussion { let nodes: [Node] 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, separator)) = 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( .container( [ .exampleLabel("Passing extra args."), .container([exampleNode, .text("-- --vault-id \"myId@$SCRIPTS/vault-gopass-client\"")], separator: " ") ], separator: separator )), at: nodes.index(after: lastExampleIndex) ) } } self.nodes = nodes } func render() -> String { nodes.render() } } enum Node { /// A container that holds onto other nodes to be rendered. indirect case container(_ nodes: [Node], separator: String = "\n\n") /// 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 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 header(_ text: String) -> Self { .styledText(text, color: .yellow, styles: [.bold]) } static func section(header: Node, content: Node, inline: Bool = false) -> Self { .container([header, content], separator: inline ? " " : "\n") } static func section(header: String, label: Node, inline: Bool = false) -> Self { .section(header: .header(header), content: label, inline: inline) } static func important(label: String) -> Self { .section( header: .text("IMPORTANT NOTE:".red.bold.underline), content: .text(label.red.italic), inline: true ) } static func note(label: String, labelInline: Bool = true) -> Self { .section(header: "NOTE:", label: .text(label.italic), inline: labelInline) } static func container(separator: String = "\n\n", _ nodes: Node...) -> Self { .container(nodes, separator: separator) } static func shellCommand(_ text: String, symbol: String = " $") -> Self { .text("\(symbol) \(text)") } static func seeAlso(label: String, command: String, appendHelpToCommand: Bool = true) -> Self { .container( .container([.header("See Also:"), .text(label.italic)], separator: " "), .shellCommand("\(appendHelpToCommand ? command + " --help" : command)") ) } static var exampleHeading: Self { .section(header: "Examples:", label: .text("Some common examples."), inline: true) } var isExampleNode: Bool { if case .example = self { return true } return false } /// 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(.section( header: .exampleLabel(label), content: .shellCommand(string, symbol: " $") )) } 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) } } } private extension Array where Element == Node { func render(separator: String = "\n\n") -> 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) } }