diff --git a/Package.swift b/Package.swift index d2dd58a..229d728 100644 --- a/Package.swift +++ b/Package.swift @@ -38,6 +38,7 @@ let package = Package( .target( name: "CliDoc", dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "ShellClient", package: "swift-shell-client") ] ), diff --git a/Sources/CliDoc/ArgumentParserExtensions/Abstract.swift b/Sources/CliDoc/ArgumentParserExtensions/Abstract.swift new file mode 100644 index 0000000..3549021 --- /dev/null +++ b/Sources/CliDoc/ArgumentParserExtensions/Abstract.swift @@ -0,0 +1,16 @@ +/// Use the `NodeBuilder` for generating an abstract. +public struct Abstract: NodeRepresentable { + @usableFromInline + let label: any NodeRepresentable + + @inlinable + public init(@NodeBuilder label: () -> any NodeRepresentable) { + self.label = label() + } + + @inlinable + public func render() -> String { + label.render() + } + +} diff --git a/Sources/CliDoc/ArgumentParserExtensions/CommandConfiguration+NodeBuilder.swift b/Sources/CliDoc/ArgumentParserExtensions/CommandConfiguration+NodeBuilder.swift new file mode 100644 index 0000000..75991d8 --- /dev/null +++ b/Sources/CliDoc/ArgumentParserExtensions/CommandConfiguration+NodeBuilder.swift @@ -0,0 +1,32 @@ +import ArgumentParser + +public extension CommandConfiguration { + + init( + commandName: String? = nil, + abstract: Abstract, + usage: String? = nil, + discussion: Discussion, + version: String = "", + shouldDisplay: Bool = true, + subcommands: [any ParsableCommand.Type] = [], + groupedSubcommands: [CommandGroup] = [], + defaultSubcommand: ParsableCommand.Type? = nil, + helpNames: NameSpecification? = nil, + aliases: [String] = [] + ) { + self.init( + commandName: commandName, + abstract: abstract.render(), + usage: usage, + discussion: discussion.render(), + version: version, + shouldDisplay: shouldDisplay, + subcommands: subcommands, + groupedSubcommands: groupedSubcommands, + defaultSubcommand: defaultSubcommand, + helpNames: helpNames, + aliases: aliases + ) + } +} diff --git a/Sources/CliDoc/ArgumentParserExtensions/Discussion.swift b/Sources/CliDoc/ArgumentParserExtensions/Discussion.swift new file mode 100644 index 0000000..0717bde --- /dev/null +++ b/Sources/CliDoc/ArgumentParserExtensions/Discussion.swift @@ -0,0 +1,16 @@ +/// Use the `NodeBuilder` for generating a discussion. +public struct Discussion: NodeRepresentable { + @usableFromInline + let label: any NodeRepresentable + + @inlinable + public init(@NodeBuilder label: () -> any NodeRepresentable) { + self.label = label() + } + + @inlinable + public func render() -> String { + label.render() + } + +} diff --git a/Sources/CliDoc/BuilderNodes.swift b/Sources/CliDoc/BuilderNodes.swift deleted file mode 100644 index ff7cfdc..0000000 --- a/Sources/CliDoc/BuilderNodes.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -// swiftlint:disable type_name -public enum _ConditionalNode< - TrueNode: NodeRepresentable, - FalseNode: NodeRepresentable ->: NodeRepresentable { - case first(TrueNode) - case second(FalseNode) - - public func render() -> String { - switch self { - case let .first(node): return node.render() - case let .second(node): return node.render() - } - } -} - -public struct _ManyNode: NodeRepresentable { - @usableFromInline - let nodes: [any NodeRepresentable] - - @usableFromInline - let separator: any NodeRepresentable - - @inlinable - public init(_ nodes: [any NodeRepresentable], separator: any NodeRepresentable) { - self.nodes = nodes - self.separator = separator - } - - @inlinable - public func render() -> String { - nodes.map { $0.render() } - .joined(separator: separator.render()) - } - -} - -// swiftlint:enable type_name diff --git a/Sources/CliDoc/Modifiers/AnyModifier.swift b/Sources/CliDoc/Modifiers/AnyModifier.swift new file mode 100644 index 0000000..8780264 --- /dev/null +++ b/Sources/CliDoc/Modifiers/AnyModifier.swift @@ -0,0 +1,14 @@ +public struct AnyNodeModifier: NodeModifier { + @usableFromInline + let modifier: any NodeModifier + + @inlinable + public init(_ modifier: N) { + self.modifier = modifier + } + + @inlinable + public func render(_ node: any NodeRepresentable) -> any NodeRepresentable { + modifier.render(node) + } +} diff --git a/Sources/CliDoc/Modifiers/Color.swift b/Sources/CliDoc/Modifiers/Color.swift new file mode 100644 index 0000000..8cca8e9 --- /dev/null +++ b/Sources/CliDoc/Modifiers/Color.swift @@ -0,0 +1,35 @@ +import Rainbow + +public extension NodeRepresentable { + + @inlinable + func color(_ color: NamedColor) -> any NodeRepresentable { + modifier(ColorModifier(color: color)) + } + +} + +public extension NodeModifier { + + @inlinable + static func color(_ color: NamedColor) -> Self where Self == AnyNodeModifier { + .init(ColorModifier(color: color)) + } + +} + +@usableFromInline +struct ColorModifier: NodeModifier, @unchecked Sendable { + @usableFromInline + let color: NamedColor + + @usableFromInline + init(color: NamedColor) { + self.color = color + } + + @usableFromInline + func render(_ node: any NodeRepresentable) -> any NodeRepresentable { + node.render().applyingColor(color) + } +} diff --git a/Sources/CliDoc/Modifiers/LabelStyle.swift b/Sources/CliDoc/Modifiers/LabelStyle.swift new file mode 100644 index 0000000..e85fe1b --- /dev/null +++ b/Sources/CliDoc/Modifiers/LabelStyle.swift @@ -0,0 +1,48 @@ +import Rainbow + +public extension NodeRepresentable { + + @inlinable + func labelStyle(_ style: any NodeModifier) -> any NodeRepresentable { + return modifier(LabelStyleModifier(style)) + } + + @inlinable + func labelStyle(_ color: NamedColor) -> any NodeRepresentable { + return modifier(LabelStyleModifier(.color(color))) + } + + @inlinable + func labelStyle(_ style: Style) -> any NodeRepresentable { + return modifier(LabelStyleModifier(.style(style))) + } + + @inlinable + func labelStyle(_ styles: Style...) -> any NodeRepresentable { + return modifier(LabelStyleModifier(.style(styles))) + } +} + +@usableFromInline +struct LabelStyleModifier: NodeModifier { + + @usableFromInline + let modifier: any NodeModifier + + @usableFromInline + init(_ modifier: any NodeModifier) { + self.modifier = modifier + } + + @usableFromInline + func render(_ node: any NodeRepresentable) -> any NodeRepresentable { + if let node = node as? LabeledContent { + return LabeledContent(separator: node.separator) { + node.label.modifier(modifier) + } content: { + node.content + } + } + return node + } +} diff --git a/Sources/CliDoc/Modifiers/Repeating.swift b/Sources/CliDoc/Modifiers/Repeating.swift new file mode 100644 index 0000000..dfb39f7 --- /dev/null +++ b/Sources/CliDoc/Modifiers/Repeating.swift @@ -0,0 +1,44 @@ +public extension NodeRepresentable { + @inlinable + func repeating(_ count: Int, separator: (any NodeRepresentable)? = nil) -> any NodeRepresentable { + modifier(RepeatingNode(count: count, separator: separator)) + } +} + +@usableFromInline +struct RepeatingNode: NodeModifier { + + @usableFromInline + let count: Int + + @usableFromInline + let separator: (any NodeRepresentable)? + + @usableFromInline + init( + count: Int, + separator: (any NodeRepresentable)? + ) { + self.count = count + self.separator = separator + } + + @usableFromInline + func render(_ node: any NodeRepresentable) -> any NodeRepresentable { + let input = node.render() + var output = input + let separator = self.separator != nil + ? self.separator!.render() + : "" + + guard count > 0 else { return output } + + var count = count - 1 + while count > 0 { + output = "\(output)\(separator)\(input)" + count -= 1 + } + + return output + } +} diff --git a/Sources/CliDoc/Modifiers/Style.swift b/Sources/CliDoc/Modifiers/Style.swift new file mode 100644 index 0000000..922e8a2 --- /dev/null +++ b/Sources/CliDoc/Modifiers/Style.swift @@ -0,0 +1,41 @@ +import Rainbow + +public extension NodeRepresentable { + @inlinable + func style(_ styles: Style...) -> any NodeRepresentable { + modifier(StyleModifier(styles: styles)) + } + + @inlinable + func style(_ styles: [Style]) -> any NodeRepresentable { + modifier(StyleModifier(styles: styles)) + } +} + +public extension NodeModifier { + @inlinable + static func style(_ styles: Style...) -> Self where Self == AnyNodeModifier { + .init(StyleModifier(styles: styles)) + } + + @inlinable + static func style(_ styles: [Style]) -> Self where Self == AnyNodeModifier { + .init(StyleModifier(styles: styles)) + } +} + +@usableFromInline +struct StyleModifier: NodeModifier, @unchecked Sendable { + @usableFromInline + let styles: [Style] + + @usableFromInline + init(styles: [Style]) { + self.styles = styles + } + + @usableFromInline + func render(_ node: any NodeRepresentable) -> any NodeRepresentable { + styles.reduce(node.render()) { $0.applyingStyle($1) } + } +} diff --git a/Sources/CliDoc/NodeBuilder.swift b/Sources/CliDoc/NodeBuilder.swift index e9f6041..69e8497 100644 --- a/Sources/CliDoc/NodeBuilder.swift +++ b/Sources/CliDoc/NodeBuilder.swift @@ -28,4 +28,75 @@ public enum NodeBuilder { .second(component) } + public static func buildBlock(_ components: N...) -> _ManyNode { + .init(components) + } + + // This breaks things ?? +// public static func buildExpression(_ expression: N) -> AnyNode { +// expression.eraseToAnyNode() +// } + + public static func buildOptional(_ component: N?) -> AnyNode { + component?.eraseToAnyNode() ?? AnyNode { Text.empty() } + } + + public static func buildFinalResult(_ component: N) -> N { + component + } + + public static func buildLimitedAvailability(_ component: N) -> N { + component + } + } + +// These are nodes that are only used by the `NodeBuilder` / internally. + +// swiftlint:disable type_name +public enum _ConditionalNode< + TrueNode: NodeRepresentable, + FalseNode: NodeRepresentable +>: NodeRepresentable { + case first(TrueNode) + case second(FalseNode) + + public func render() -> String { + switch self { + case let .first(node): return node.render() + case let .second(node): return node.render() + } + } +} + +public struct _ManyNode: NodeRepresentable { + @usableFromInline + let nodes: [any NodeRepresentable] + + @usableFromInline + let separator: any NodeRepresentable + + @usableFromInline + init( + _ nodes: [any NodeRepresentable], + separator: AnyNode = Text.empty().eraseToAnyNode() + ) { + self.nodes = nodes + self.separator = separator + } + + @inlinable + public init(_ nodes: [any NodeRepresentable], separator: any NodeRepresentable) { + self.nodes = nodes + self.separator = separator + } + + @inlinable + public func render() -> String { + nodes.map { $0.render() } + .joined(separator: separator.render()) + } + +} + +// swiftlint:enable type_name diff --git a/Sources/CliDoc/NodeModifier.swift b/Sources/CliDoc/NodeModifier.swift new file mode 100644 index 0000000..788ca25 --- /dev/null +++ b/Sources/CliDoc/NodeModifier.swift @@ -0,0 +1,9 @@ +public protocol NodeModifier: Sendable { + func render(_ node: any NodeRepresentable) -> any NodeRepresentable +} + +public extension NodeRepresentable { + func modifier(_ modifier: NodeModifier) -> any NodeRepresentable { + modifier.render(self) + } +} diff --git a/Sources/CliDoc/Nodes/Group.swift b/Sources/CliDoc/Nodes/Group.swift index 5d269a4..3af1320 100644 --- a/Sources/CliDoc/Nodes/Group.swift +++ b/Sources/CliDoc/Nodes/Group.swift @@ -1,4 +1,3 @@ - public struct Group: NodeRepresentable { @usableFromInline let node: any NodeRepresentable @@ -25,10 +24,10 @@ public struct Group: NodeRepresentable { @inlinable public init( - separator: AnyNode = Space().eraseToAnyNode(), + separator: String = " ", @NodeBuilder _ build: () -> any NodeRepresentable ) { - self.init(separator: separator, node: build()) + self.init(separator: separator.eraseToAnyNode(), node: build()) } @inlinable diff --git a/Sources/CliDoc/Nodes/Header.swift b/Sources/CliDoc/Nodes/Header.swift index 9c2445d..b60937e 100644 --- a/Sources/CliDoc/Nodes/Header.swift +++ b/Sources/CliDoc/Nodes/Header.swift @@ -1,32 +1,45 @@ @preconcurrency import Rainbow -public struct Header: NodeRepresentable { +public struct Header: NodeRepresentable, @unchecked Sendable { @usableFromInline - let node: Text + let node: any NodeRepresentable @usableFromInline - let styles: [Style] + let color: NamedColor? + + @usableFromInline + let styles: [Style]? + + @inlinable + public init( + @NodeBuilder _ build: () -> any NodeRepresentable + ) { + self.node = build() + self.color = nil + self.styles = nil + } @inlinable public init( _ text: String, color: NamedColor? = .yellow, - styles: [Style] = [.bold] + styles: [Style]? = [.bold] ) { - var text = Text(text) - if let color { - text = text.color(color) - } - self.node = text + self.color = color + self.node = Text(text) self.styles = styles } @inlinable public func render() -> String { - styles.reduce(node) { node, style in - node.style(style) + var node = node + if let color { + node = node.color(color) } - .render() + if let styles { + node = node.style(styles) + } + return node.render() } } diff --git a/Sources/CliDoc/Nodes/LabeledContent.swift b/Sources/CliDoc/Nodes/LabeledContent.swift new file mode 100644 index 0000000..da55109 --- /dev/null +++ b/Sources/CliDoc/Nodes/LabeledContent.swift @@ -0,0 +1,46 @@ +public struct LabeledContent: NodeRepresentable { + + @usableFromInline + let label: any NodeRepresentable + + @usableFromInline + let content: any NodeRepresentable + + @usableFromInline + let separator: any NodeRepresentable + + @inlinable + public init( + separator: (any NodeRepresentable)? = nil, + @NodeBuilder label: () -> any NodeRepresentable, + @NodeBuilder content: () -> any NodeRepresentable + ) { + self.separator = separator ?? "\n" + self.label = label() + self.content = content() + } + + @inlinable + public func render() -> String { + Group(separator: separator) { + label + content + }.render() + } +} + +public extension LabeledContent { + + @inlinable + init( + _ label: String, + separator: (any NodeRepresentable)? = nil, + @NodeBuilder content: () -> any NodeRepresentable + ) { + self.init(separator: separator) { + Text(label) + } content: { + content() + } + } +} diff --git a/Sources/CliDoc/Nodes/NodeRepresentable+repeating.swift b/Sources/CliDoc/Nodes/NodeRepresentable+repeating.swift deleted file mode 100644 index 4e538c5..0000000 --- a/Sources/CliDoc/Nodes/NodeRepresentable+repeating.swift +++ /dev/null @@ -1,34 +0,0 @@ -public extension NodeRepresentable { - @inlinable - func repeating(_ count: Int) -> some NodeRepresentable { - RepeatingNode(self, count: count) - } -} - -@usableFromInline -struct RepeatingNode: NodeRepresentable { - - @usableFromInline - let node: any NodeRepresentable - - @usableFromInline - let count: Int - - @usableFromInline - init(_ node: any NodeRepresentable, count: Int) { - self.node = node - self.count = count - } - - @usableFromInline - func render() -> String { - var string = node.render() - guard count > 0 else { return string } - var count = count - 1 - while count > 0 { - string = "\(string)\(node.render())" - count -= 1 - } - return string - } -} diff --git a/Sources/CliDoc/Nodes/Section.swift b/Sources/CliDoc/Nodes/Section.swift deleted file mode 100644 index c02a9b8..0000000 --- a/Sources/CliDoc/Nodes/Section.swift +++ /dev/null @@ -1,41 +0,0 @@ -// TODO: Remove init(header:...) initializers. -public struct Section: NodeRepresentable { - @usableFromInline - let node: any NodeRepresentable - - @inlinable - public init(@NodeBuilder _ build: () -> N) { - self.node = build() - } - - @inlinable - public func render() -> String { - node.render() - } - - @inlinable - public init( - header: String, - separator: Separator, - @NodeBuilder content: () -> Content - ) { - self.init { - Text(header) - separator - content() - } - } - - @inlinable - public init( - header: String, - inline: Bool = false, - @NodeBuilder content: () -> Content - ) { - self.init( - header: header, - separator: inline ? Space().eraseToAnyNode() : NewLine().eraseToAnyNode(), - content: content - ) - } -} diff --git a/Sources/CliDoc/Nodes/String+NodeRepresentable.swift b/Sources/CliDoc/Nodes/String+NodeRepresentable.swift new file mode 100644 index 0000000..49c61a3 --- /dev/null +++ b/Sources/CliDoc/Nodes/String+NodeRepresentable.swift @@ -0,0 +1,6 @@ +extension String: NodeRepresentable { + @inlinable + public func render() -> String { + self + } +} diff --git a/Sources/CliDoc/Nodes/Text.swift b/Sources/CliDoc/Nodes/Text.swift index 1b1341f..a2f8212 100644 --- a/Sources/CliDoc/Nodes/Text.swift +++ b/Sources/CliDoc/Nodes/Text.swift @@ -13,43 +13,6 @@ public struct Text: NodeRepresentable { public func render() -> String { text } - - @inlinable - public func color(_ color: NamedColor) -> Self { - .init(text.applyingColor(color)) - } - - @inlinable - public func style(_ style: Style) -> Self { - .init(text.applyingStyle(style)) - } -} - -public struct NewLine: NodeRepresentable { - @usableFromInline - let node: any NodeRepresentable - - @inlinable - public init(count: Int = 1) { - self.node = Text("\n").repeating(count) - } - - @inlinable - public func render() -> String { - node.render() - } -} - -public struct Space: NodeRepresentable { - let node: any NodeRepresentable - - public init(count: Int = 1) { - self.node = Text(" ").repeating(count) - } - - public func render() -> String { - node.render() - } } public extension NodeRepresentable { @@ -58,12 +21,12 @@ public extension NodeRepresentable { Text("") } - static func newLine(count: Int = 1) -> Self where Self == NewLine { - NewLine(count: count) + static func newLine(count: Int = 1) -> Self where Self == AnyNode { + "\n".repeating(count).eraseToAnyNode() } - static func space(count: Int = 1) -> Self where Self == Space { - Space(count: count) + static func space(count: Int = 1) -> Self where Self == AnyNode { + " ".repeating(count).eraseToAnyNode() } } diff --git a/Tests/CliDocTests/CliDocTests.swift b/Tests/CliDocTests/CliDocTests.swift index 3b31782..6eaaca2 100644 --- a/Tests/CliDocTests/CliDocTests.swift +++ b/Tests/CliDocTests/CliDocTests.swift @@ -1,70 +1,99 @@ @_spi(Internal) import CliDoc +@preconcurrency import Rainbow import Testing +import XCTest -@Test -func testNodeBuilder() { - let node = AnyNode { - Text("foo").color(.green).style(.bold) - NewLine() - Text("bar").repeating(2) +final class CliDocTests: XCTestCase { + + override func setUp() { + super.setUp() + Rainbow.outputTarget = .console + Rainbow.enabled = true } - let expected = """ - \("foo".green.bold) - barbar - """ - #expect(node.render() == expected) -} -@Test( - arguments: [ - (true, "foo bar"), - (false, """ - foo - bar - """) - ] -) -func testSection( - inline: Bool, - expected: String -) { - let node = AnyNode { - Section( - header: "foo", - separator: inline ? Space().eraseToAnyNode() : NewLine().eraseToAnyNode() - ) { - Text("bar") + func testStringChecks() { + let expected = "Foo".green.bold + let notexpected = "Foo" + + XCTAssert("Foo".green.bold == expected) + XCTAssert("Foo".green.bold != notexpected) + } + + func testRepeatingModifier() { + let node = AnyNode { + Text("foo").color(.green).style(.bold) + "\n".repeating(2) + Text("bar").repeating(2, separator: " ") + } + let expected = """ + \("foo".green.bold) + + bar bar + """ + XCTAssert(node.render() == expected) + } + + func testGroup1() { + let arguments = [ + (true, "foo bar"), + (false, """ + foo + bar + """) + ] + + for (inline, expected) in arguments { + let node = AnyNode { + Group(separator: inline ? " " : "\n") { + Text("foo") + Text("bar") + } + } + XCTAssert(node.render() == expected) } } - print(node.render()) - #expect(node.render() == expected) -} + func testHeader() { + let header = Header("Foo") + let expected = "\("Foo".yellow.bold)" + XCTAssert(header.render() == expected) -@Test -func testHeader() { - let header = Header("Foo") - let expected = "\("Foo".yellow.bold)" - #expect(header.render() == expected) -} - -@Test -func testGroup() { - let group = Group { - Text("foo") - Text("bar") + let header2 = Header { + "Foo".yellow.bold + } + XCTAssert(header2.render() == expected) } - print(group.render()) - #expect(group.render() == "foo bar") + func testGroup() { + let group = Group { + Text("foo") + Text("bar") + } - let group2 = Group(separator: .newLine()) { - Text("foo") - Text("bar") + XCTAssert(group.render() == "foo bar") + + let group2 = Group(separator: "\n") { + Text("foo") + Text("bar") + } + let expected = """ + foo + bar + """ + XCTAssert(group2.render() == expected) + } + + func testLabeledContent() { + let node = LabeledContent("Foo") { + Text("Bar") + } + .labelStyle(.green) + .labelStyle(.bold) + + let expected = """ + \("Foo".green.bold) + Bar + """ + XCTAssert(node.render() == expected) } - let expected = """ - foo - bar - """ - #expect(group2.render() == expected) }