diff --git a/Package.swift b/Package.swift index 229d728..f00b788 100644 --- a/Package.swift +++ b/Package.swift @@ -21,6 +21,7 @@ let package = Package( name: "hpa", dependencies: [ "CliClient", + "CliDoc", .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "ShellClient", package: "swift-shell-client") diff --git a/Sources/CliDoc/ArgumentParserExtensions/CommandConfiguration+NodeBuilder.swift b/Sources/CliDoc/ArgumentParserExtensions/CommandConfiguration+NodeBuilder.swift index 75991d8..acf10e9 100644 --- a/Sources/CliDoc/ArgumentParserExtensions/CommandConfiguration+NodeBuilder.swift +++ b/Sources/CliDoc/ArgumentParserExtensions/CommandConfiguration+NodeBuilder.swift @@ -2,6 +2,7 @@ import ArgumentParser public extension CommandConfiguration { + /// Use `NodeBuilder` syntax for generating the abstract and discussion parameters. init( commandName: String? = nil, abstract: Abstract, diff --git a/Sources/CliDoc/Modifiers/LabeledContentStyle.swift b/Sources/CliDoc/Modifiers/LabeledContentStyle.swift new file mode 100644 index 0000000..fc0e9ce --- /dev/null +++ b/Sources/CliDoc/Modifiers/LabeledContentStyle.swift @@ -0,0 +1,58 @@ +public enum LabelContentStyle: Sendable { + case inline + case `default` + case custom(any NodeRepresentable) +} + +public extension NodeRepresentable { + @inlinable + func labeledContentStyle(_ style: LabelContentStyle) -> any NodeRepresentable { + modifier(LabeledContentStyleModifier(style: style)) + } +} + +@usableFromInline +struct LabeledContentStyleModifier: NodeModifier { + + @usableFromInline + let style: LabelContentStyle + + @usableFromInline + init(style: LabelContentStyle) { + self.style = style + } + + @usableFromInline + func render(_ node: any NodeRepresentable) -> any NodeRepresentable { + if let many = node as? _ManyNode { + var nodes = many.nodes + print("nodes: \(nodes)") + for (idx, node) in nodes.enumerated() { + if let labeled = node as? LabeledContent { + let newNode = labeled.applyingContentStyle(style) + nodes[idx] = newNode + } + } + return _ManyNode(nodes, separator: many.separator) + } else if let labeled = node as? LabeledContent { + return labeled.applyingContentStyle(style) + } + print("no many or labeled nodes found in: \(node)") + return node + } + +} + +private extension LabeledContent { + + func applyingContentStyle(_ style: LabelContentStyle) -> Self { + switch style { + case .inline: + return .init(separator: " ") { label } content: { content } + case .default: + return .init(separator: "\n") { label } content: { content } + case let .custom(custom): + return .init(separator: custom) { label } content: { content } + } + } +} diff --git a/Sources/CliDoc/Node.swift b/Sources/CliDoc/Node.swift new file mode 100644 index 0000000..b2a69fd --- /dev/null +++ b/Sources/CliDoc/Node.swift @@ -0,0 +1,26 @@ +// swiftlint:disable type_name +public protocol Node: NodeRepresentable { + associatedtype _Body: NodeRepresentable + typealias Body = _Body + + @NodeBuilder + var body: Body { get } +} + +// swiftlint:enable type_name + +public extension Node { + @inlinable + func render() -> String { + body.render() + } +} + +public struct Foo: Node { + + public init() {} + + public var body: some NodeRepresentable { + LabeledContent("Foo") { "Bar" } + } +} diff --git a/Sources/CliDoc/NodeBuilder.swift b/Sources/CliDoc/NodeBuilder.swift index 69e8497..5a46ae7 100644 --- a/Sources/CliDoc/NodeBuilder.swift +++ b/Sources/CliDoc/NodeBuilder.swift @@ -16,16 +16,16 @@ public enum NodeBuilder { .init(components, separator: .empty()) } - public static func buildEither( - first component: N0 - ) -> _ConditionalNode { - .first(component) + public static func buildEither( + first component: N + ) -> N { + component } - public static func buildEither( - second component: N1 - ) -> _ConditionalNode { - .second(component) + public static func buildEither( + second component: N + ) -> N { + component } public static func buildBlock(_ components: N...) -> _ManyNode { @@ -37,8 +37,13 @@ public enum NodeBuilder { // expression.eraseToAnyNode() // } - public static func buildOptional(_ component: N?) -> AnyNode { - component?.eraseToAnyNode() ?? AnyNode { Text.empty() } + public static func buildOptional(_ component: N?) -> _ConditionalNode { + switch component { + case let .some(node): + return .first(node) + case .none: + return .second(.empty()) + } } public static func buildFinalResult(_ component: N) -> N { @@ -79,15 +84,18 @@ public struct _ManyNode: NodeRepresentable { @usableFromInline init( _ nodes: [any NodeRepresentable], - separator: AnyNode = Text.empty().eraseToAnyNode() + separator: any NodeRepresentable = "\n" as any NodeRepresentable ) { - self.nodes = nodes - self.separator = separator - } - - @inlinable - public init(_ nodes: [any NodeRepresentable], separator: any NodeRepresentable) { - self.nodes = nodes + // Flatten the nodes. + var allNodes = [any NodeRepresentable]() + for node in nodes { + if let many = node as? Self { + allNodes += many.nodes + } else { + allNodes.append(node) + } + } + self.nodes = allNodes self.separator = separator } diff --git a/Sources/CliDoc/Nodes/Section.swift b/Sources/CliDoc/Nodes/Section.swift new file mode 100644 index 0000000..b761ca5 --- /dev/null +++ b/Sources/CliDoc/Nodes/Section.swift @@ -0,0 +1,30 @@ +public struct Section: NodeRepresentable { + + @usableFromInline + let content: any NodeRepresentable + + public init(@NodeBuilder content: () -> any NodeRepresentable) { + self.content = content() + } + + public func render() -> String { + guard let many = content as? _ManyNode else { + return content.render() + "\n\n" + } + + let node = _ManyNode(many.nodes, separator: "\n\n") + return node.render() + +// var output = "" +// let lastIndex = many.nodes.count - 1 +// +// for (idx, content) in many.nodes.enumerated() { +// if idx != lastIndex { +// output += content.render() + "\n\n" +// } else { +// output += content.render() + "\n" +// } +// } +// return output + } +} diff --git a/Sources/CliDoc/Nodes/ShellCommand.swift b/Sources/CliDoc/Nodes/ShellCommand.swift new file mode 100644 index 0000000..317acd9 --- /dev/null +++ b/Sources/CliDoc/Nodes/ShellCommand.swift @@ -0,0 +1,41 @@ +public struct ShellCommand: NodeRepresentable { + + @usableFromInline + let symbol: any NodeRepresentable + + @usableFromInline + let command: any NodeRepresentable + + @inlinable + public init( + @NodeBuilder symbol: () -> any NodeRepresentable, + @NodeBuilder command: () -> any NodeRepresentable + ) { + self.symbol = symbol() + self.command = command() + } + + @inlinable + public init( + symbol: String = " $", + @NodeBuilder command: () -> any NodeRepresentable + ) { + self.init { symbol } command: { command() } + } + + @inlinable + public init( + symbol: String = " $", + command: String + ) { + self.init { symbol } command: { command } + } + + @inlinable + public func render() -> String { + Group(separator: " ") { + symbol + command + }.render() + } +} diff --git a/Sources/hpa/CreateCommand.swift b/Sources/hpa/CreateCommand.swift index 638b984..ac9015a 100644 --- a/Sources/hpa/CreateCommand.swift +++ b/Sources/hpa/CreateCommand.swift @@ -8,7 +8,7 @@ struct CreateCommand: AsyncParsableCommand { static let commandName = "create" - static let configuration = CommandConfiguration.playbook( + static let configuration = CommandConfiguration.playbook2( commandName: commandName, abstract: "Create a home performance assesment project.", examples: ( diff --git a/Sources/hpa/Internal/CommandConfigurationExtensions.swift b/Sources/hpa/Internal/CommandConfigurationExtensions.swift index 6d1f48e..88545e9 100644 --- a/Sources/hpa/Internal/CommandConfigurationExtensions.swift +++ b/Sources/hpa/Internal/CommandConfigurationExtensions.swift @@ -1,4 +1,5 @@ import ArgumentParser +import CliDoc import Rainbow extension CommandConfiguration { @@ -29,6 +30,25 @@ extension CommandConfiguration { ) } + 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, @@ -52,6 +72,7 @@ func createAbstract(_ string: String) -> String { "\(string.blue)" } +// TODO: Remove struct Discussion { let nodes: [Node] diff --git a/Sources/hpa/Internal/Discussion+playbook.swift b/Sources/hpa/Internal/Discussion+playbook.swift new file mode 100644 index 0000000..c9ad0de --- /dev/null +++ b/Sources/hpa/Internal/Discussion+playbook.swift @@ -0,0 +1,120 @@ +import CliDoc + +extension CliDoc.Discussion { + static func playbook( + examples: [(label: String, example: String)] + ) -> Self { + .init { + Section { + Note.mostOptionsNotRequired + Examples(examples: examples) + SeeAlso(label: "Ansible playbook options.", command: "ansible-playbook --help") + ImportantNote.passingExtraArgs + } + .labeledContentStyle(.custom("foo:")) + } + } +} + +extension ShellCommand { + static func hpaCommand(_ command: String) -> Self { + .init(command: "\(Constants.appName) \(command)") + } +} + +struct SeeAlso: NodeRepresentable { + + let node: any NodeRepresentable + + init(label: String, command: String) { + self.node = Group(separator: "\n") { + Note("SEE ALSO:") { + label + } + ShellCommand(command: command) + } + } + + func render() -> String { + node.render() + } + +} + +struct Examples: NodeRepresentable { + typealias Example = (label: String, example: String) + let examples: [Example] + + func render() -> String { + Group(separator: "\n") { + Note("EXAMPLES:") { "Common usage examples." } + for (label, command) in examples { + LabeledContent("\t\(label.green.bold)", separator: "\n") { + ShellCommand.hpaCommand(command) + } + } + if let first = examples.first { + LabeledContent("\n\tPassing extra options.".green.bold, separator: "\n") { + ShellCommand.hpaCommand("\(first.example) -- --vault-id \"myId@$SCRIPTS/vault-gopass-client\"") + } + } + }.render() + } +} + +struct Note: NodeRepresentable { + + let node: any NodeRepresentable + + init( + _ label: String = "NOTE:", + @NodeBuilder _ content: () -> any NodeRepresentable + ) { + self.node = LabeledContent( + separator: " ", + label: { label.yellow.bold }, + content: { content().style(.italic) } + ) + } + + func render() -> String { + node.render() + } + + static var mostOptionsNotRequired: Self { + .init { + "Most options are not required if you have a configuration file setup." + } + } +} + +// TODO: Fix the text. +struct ImportantNote: NodeRepresentable { + + let node: any NodeRepresentable + + init( + _ label: String = "IMPORTANT NOTE:", + @NodeBuilder _ content: () -> any NodeRepresentable + ) { + self.node = LabeledContent( + separator: "\n", + label: { label.red.bold.underline }, + content: { content().style(.italic) } + ) + } + + func render() -> String { + node.render() + } + + static var passingExtraArgs: Self { + .init { + """ + Any extra options passed to the underlying command need to come after + `--` otherwise an "Unknown option" error will occur. See above example of passing + extra options. + """ + } + } +} diff --git a/Tests/CliDocTests/CliDocTests.swift b/Tests/CliDocTests/CliDocTests.swift index 6eaaca2..0137e0a 100644 --- a/Tests/CliDocTests/CliDocTests.swift +++ b/Tests/CliDocTests/CliDocTests.swift @@ -13,10 +13,9 @@ final class CliDocTests: XCTestCase { func testStringChecks() { let expected = "Foo".green.bold - let notexpected = "Foo" XCTAssert("Foo".green.bold == expected) - XCTAssert("Foo".green.bold != notexpected) + XCTAssert(expected != "Foo") } func testRepeatingModifier() { @@ -96,4 +95,35 @@ final class CliDocTests: XCTestCase { """ XCTAssert(node.render() == expected) } + + func testShellCommand() { + let node = ShellCommand { + "ls -lah" + } + let expected = " $ ls -lah" + XCTAssert(node.render() == expected) + } + + func testDiscussion() { + let node = Discussion { + Group(separator: "\n") { + LabeledContent(separator: " ") { + "NOTE:".yellow.bold + } content: { + "Foo" + } + } + } + + let expected = """ + \("NOTE:".yellow.bold) Foo + """ + + XCTAssert(node.render() == expected) + } + + func testFooNode() { + let foo = Foo() + XCTAssertNotNil(foo.body as? LabeledContent) + } }