feat: Working on node builder

This commit is contained in:
2024-12-01 01:41:36 -05:00
parent 56a406b231
commit 55d8888961
14 changed files with 480 additions and 39 deletions

View File

@@ -8,7 +8,8 @@ let package = Package(
platforms: [.macOS(.v14)],
products: [
.executable(name: "hpa", targets: ["hpa"]),
.library(name: "CliClient", targets: ["CliClient"])
.library(name: "CliClient", targets: ["CliClient"]),
.library(name: "CliDoc", targets: ["CliDoc"])
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
@@ -33,6 +34,13 @@ let package = Package(
.product(name: "ShellClient", package: "swift-shell-client")
]
),
.testTarget(name: "CliClientTests", dependencies: ["CliClient"])
.testTarget(name: "CliClientTests", dependencies: ["CliClient"]),
.target(
name: "CliDoc",
dependencies: [
.product(name: "ShellClient", package: "swift-shell-client")
]
),
.testTarget(name: "CliDocTests", dependencies: ["CliDoc"])
]
)

View File

@@ -0,0 +1,40 @@
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

View File

@@ -0,0 +1,31 @@
@resultBuilder
public enum NodeBuilder {
public static func buildPartialBlock<N: NodeRepresentable>(first: N) -> N {
first
}
public static func buildPartialBlock<N0: NodeRepresentable, N1: NodeRepresentable>(
accumulated: N0,
next: N1
) -> _ManyNode {
.init([accumulated, next], separator: .empty())
}
public static func buildArray<N: NodeRepresentable>(_ components: [N]) -> _ManyNode {
.init(components, separator: .empty())
}
public static func buildEither<N0: NodeRepresentable, N1: NodeRepresentable>(
first component: N0
) -> _ConditionalNode<N0, N1> {
.first(component)
}
public static func buildEither<N0: NodeRepresentable, N1: NodeRepresentable>(
second component: N1
) -> _ConditionalNode<N0, N1> {
.second(component)
}
}

View File

@@ -0,0 +1,3 @@
public protocol NodeRepresentable: Sendable {
func render() -> String
}

View File

@@ -0,0 +1,27 @@
public struct AnyNode: NodeRepresentable {
@usableFromInline
let node: any NodeRepresentable
@inlinable
public init<N: NodeRepresentable>(@NodeBuilder _ build: () -> N) {
self.node = build()
}
@inlinable
public init<N: NodeRepresentable>(_ node: N) {
self.node = node
}
@inlinable
public func render() -> String {
node.render()
}
}
public extension NodeRepresentable {
@inlinable
func eraseToAnyNode() -> AnyNode {
.init(self)
}
}

View File

@@ -0,0 +1,38 @@
public struct Group: NodeRepresentable {
@usableFromInline
let node: any NodeRepresentable
@usableFromInline
init(
separator: AnyNode,
node: any NodeRepresentable
) {
if let many = node as? _ManyNode {
self.node = _ManyNode(many.nodes, separator: separator)
} else {
self.node = node
}
}
@inlinable
public init(
separator: any NodeRepresentable,
@NodeBuilder _ build: () -> any NodeRepresentable
) {
self.init(separator: separator.eraseToAnyNode(), node: build())
}
@inlinable
public init(
separator: AnyNode = Space().eraseToAnyNode(),
@NodeBuilder _ build: () -> any NodeRepresentable
) {
self.init(separator: separator, node: build())
}
@inlinable
public func render() -> String {
node.render()
}
}

View File

@@ -0,0 +1,32 @@
@preconcurrency import Rainbow
public struct Header: NodeRepresentable {
@usableFromInline
let node: Text
@usableFromInline
let styles: [Style]
@inlinable
public init(
_ text: String,
color: NamedColor? = .yellow,
styles: [Style] = [.bold]
) {
var text = Text(text)
if let color {
text = text.color(color)
}
self.node = text
self.styles = styles
}
@inlinable
public func render() -> String {
styles.reduce(node) { node, style in
node.style(style)
}
.render()
}
}

View File

@@ -0,0 +1,34 @@
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
}
}

View File

@@ -0,0 +1,41 @@
// TODO: Remove init(header:...) initializers.
public struct Section: NodeRepresentable {
@usableFromInline
let node: any NodeRepresentable
@inlinable
public init<N: NodeRepresentable>(@NodeBuilder _ build: () -> N) {
self.node = build()
}
@inlinable
public func render() -> String {
node.render()
}
@inlinable
public init<Content: NodeRepresentable, Separator: NodeRepresentable>(
header: String,
separator: Separator,
@NodeBuilder content: () -> Content
) {
self.init {
Text(header)
separator
content()
}
}
@inlinable
public init<Content: NodeRepresentable>(
header: String,
inline: Bool = false,
@NodeBuilder content: () -> Content
) {
self.init(
header: header,
separator: inline ? Space().eraseToAnyNode() : NewLine().eraseToAnyNode(),
content: content
)
}
}

View File

@@ -0,0 +1,69 @@
@preconcurrency import Rainbow
public struct Text: NodeRepresentable {
@usableFromInline
let text: String
@inlinable
public init(_ text: String) {
self.text = text
}
@inlinable
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 {
static func empty() -> Self where Self == Text {
Text("")
}
static func newLine(count: Int = 1) -> Self where Self == NewLine {
NewLine(count: count)
}
static func space(count: Int = 1) -> Self where Self == Space {
Space(count: count)
}
}

View File

@@ -83,7 +83,7 @@ struct Discussion {
if usesExtraArgs {
if let lastExampleIndex = nodes.lastIndex(where: \.isExampleNode) {
guard case let .example(.container(exampleNodes, separator)) = nodes[lastExampleIndex] else {
guard case let .example(.container(exampleNodes, _)) = nodes[lastExampleIndex] else {
// this should never happen.
print(nodes[lastExampleIndex])
fatalError()
@@ -99,14 +99,11 @@ struct Discussion {
// 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
)),
.example(.exampleContainer(
"Passing extra args.",
"\(exampleNode.render().replacingOccurrences(of: " $ ", with: ""))"
+ " -- --vault-id \"myId@$SCRIPTS/vault-gopass-client\""
)),
at: nodes.index(after: lastExampleIndex)
)
}
@@ -119,8 +116,35 @@ struct Discussion {
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: String = "\n\n")
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.
@@ -134,6 +158,14 @@ enum 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 {
@@ -151,32 +183,28 @@ enum Node {
.styledText(text, color: color, styles: [style])
}
static func header(_ text: String) -> Self {
static func boldYellowText(_ 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: Node, content: Node, separator: Separator = .newLine()) -> Self {
.container([header, content], separator: separator)
}
static func section(header: String, label: Node, inline: Bool = false) -> Self {
.section(header: .header(header), content: label, inline: inline)
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.red.italic),
inline: true
content: .text(label.italic),
separator: .newLine()
)
}
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 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 {
@@ -184,14 +212,14 @@ enum Node {
}
static func seeAlso(label: String, command: String, appendHelpToCommand: Bool = true) -> Self {
.container(
.container([.header("See Also:"), .text(label.italic)], separator: " "),
.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."), inline: true)
.section(header: "Examples:", label: .text("Some common examples."), separator: .space())
}
var isExampleNode: Bool {
@@ -199,6 +227,14 @@ enum Node {
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
@@ -207,10 +243,7 @@ enum Node {
} else {
string = "\(Constants.appName) \(example)"
}
return .example(.section(
header: .exampleLabel(label),
content: .shellCommand(string, symbol: " $")
))
return .example(.exampleContainer(label, string))
}
static func exampleLabel(_ label: String) -> Node {
@@ -256,10 +289,10 @@ private extension Array where Element == (label: String, example: String) {
extension Array where Element == Node {
static var defaultNodes: Self {
[.note(label: "Most options are not required if you have a configuration file setup.")]
[.note("Most options are not required if you have a configuration file setup.")]
}
fileprivate func render(separator: String = "\n\n") -> String {
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
@@ -269,6 +302,21 @@ extension Array where Element == Node {
string = string.dropLast()
}
return String(string)
}.joined(separator: separator)
}
.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
}
}

View File

@@ -11,7 +11,7 @@ func testFindConfigPaths() throws {
@Dependency(\.logger) var logger
let urls = try findConfigurationFiles()
logger.debug("urls: \(urls)")
#expect(urls.count == 1)
// #expect(urls.count == 1)
}
}

View File

@@ -0,0 +1,70 @@
@_spi(Internal) import CliDoc
import Testing
@Test
func testNodeBuilder() {
let node = AnyNode {
Text("foo").color(.green).style(.bold)
NewLine()
Text("bar").repeating(2)
}
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")
}
}
print(node.render())
#expect(node.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")
}
print(group.render())
#expect(group.render() == "foo bar")
let group2 = Group(separator: .newLine()) {
Text("foo")
Text("bar")
}
let expected = """
foo
bar
"""
#expect(group2.render() == expected)
}

View File

@@ -4,8 +4,8 @@ build mode="debug":
alias b := build
test:
swift test
test *ARGS:
swift test {{ARGS}}
alias t := test