From 81925a95d676f3b583e099fb80ab157b22ba516c Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Sat, 30 Nov 2024 17:42:13 -0500 Subject: [PATCH] feat: Node type done and working, which is used for styling and rendering documentation strings --- .../CommandConfigurationExtensions.swift | 194 +++++++++++++++--- Sources/hpa/Internal/Constants.swift | 12 +- Sources/hpa/VaultCommands/VaultCommand.swift | 6 + 3 files changed, 174 insertions(+), 38 deletions(-) diff --git a/Sources/hpa/Internal/CommandConfigurationExtensions.swift b/Sources/hpa/Internal/CommandConfigurationExtensions.swift index 35b1ecc..cc1e92d 100644 --- a/Sources/hpa/Internal/CommandConfigurationExtensions.swift +++ b/Sources/hpa/Internal/CommandConfigurationExtensions.swift @@ -27,7 +27,10 @@ extension CommandConfiguration { 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")] + + [ + .seeAlso(label: "Ansible playbook options.", command: "ansible-playbook"), + .important(label: Constants.importantExtraArgsNote) + ] ) } } @@ -37,7 +40,7 @@ func createAbstract(_ string: String) -> String { } struct Discussion { - var nodes: [Node] + let nodes: [Node] init(usesExtraArgs: Bool = true, _ nodes: Node...) { self.init(nodes: nodes, usesExtraArgs: usesExtraArgs) @@ -52,57 +55,188 @@ struct Discussion { if usesExtraArgs { if let lastExampleIndex = nodes.lastIndex(where: \.isExampleNode) { - let last = nodes[lastExampleIndex] - if case let .example(_, example, parent) = last { - nodes.insert( - .example(label: "Passing extra args.", example: example, parentCommand: parent), - at: nodes.index(after: lastExampleIndex) - ) + 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.map { $0.render() }.joined(separator: "\n") - } + func render() -> String { nodes.render() } } -enum Node: Equatable { - case note(header: String = "NOTE:", label: String, appendNewLine: Bool = true) - case example(label: String, example: String, parentCommand: String?) - case seeAlso(label: String, command: String, appendHelpToCommand: Bool = true) +enum Node { - static var exampleHeading: Self { .note(header: "Examples:", label: "Some common examples.", appendNewLine: false) } + /// 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 .note(header, note, appendNewLine): - return "\(header.yellow) \(note.italic)\(appendNewLine ? "\n" : "")" - case let .example(label: label, example: example, parentCommand: parent): - return """ - \(label.green.italic) - $ \(Constants.appName) \(parent ?? "")\(parent != nil ? " \(example)" : example) - - """ - case let .seeAlso(label: label, command: command, appendHelpToCommand: appendHelp): - return """ - \("See Also:".yellow) \(label.italic) - $ \(command)\(appendHelp ? " --help" : "") - - """ + 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) + } +} diff --git a/Sources/hpa/Internal/Constants.swift b/Sources/hpa/Internal/Constants.swift index 7976bd9..c35ea3f 100644 --- a/Sources/hpa/Internal/Constants.swift +++ b/Sources/hpa/Internal/Constants.swift @@ -6,12 +6,8 @@ enum Constants { static let playbookFileName = "main.yml" static let inventoryFileName = "inventory.ini" static let importantExtraArgsNote = """ - - \("IMPORTANT NOTE".bold.underline): \(""" - Any extra arguments to pass to the underlying command invocation have to - be at the end with `--` before any arguments otherwise there will - be an "Unkown option" error. See examples above. - """.italic - ) - """.red + Any extra arguments to pass to the underlying command invocation have to + be at the end with `--` before any arguments otherwise there will + be an "Unkown option" error. See examples above. + """ } diff --git a/Sources/hpa/VaultCommands/VaultCommand.swift b/Sources/hpa/VaultCommands/VaultCommand.swift index fe9697c..380e6a5 100644 --- a/Sources/hpa/VaultCommands/VaultCommand.swift +++ b/Sources/hpa/VaultCommands/VaultCommand.swift @@ -7,6 +7,12 @@ struct VaultCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: commandName, abstract: createAbstract("Vault commands."), + discussion: Discussion( + .text(""" + Allows you to run `ansible-vault` commands on your project or project-template. + """), + .seeAlso(label: "Ansible Vault", command: "ansible-vault") + ).render(), subcommands: [ EncryptCommand.self, DecryptCommand.self ]