Compare commits
3 Commits
56a406b231
...
96a1fac07b
| Author | SHA1 | Date | |
|---|---|---|---|
|
96a1fac07b
|
|||
|
ff49b12198
|
|||
|
55d8888961
|
@@ -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"),
|
||||
@@ -20,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")
|
||||
@@ -33,6 +35,14 @@ 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: "ArgumentParser", package: "swift-argument-parser"),
|
||||
.product(name: "ShellClient", package: "swift-shell-client")
|
||||
]
|
||||
),
|
||||
.testTarget(name: "CliDocTests", dependencies: ["CliDoc"])
|
||||
]
|
||||
)
|
||||
|
||||
16
Sources/CliDoc/ArgumentParserExtensions/Abstract.swift
Normal file
16
Sources/CliDoc/ArgumentParserExtensions/Abstract.swift
Normal file
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import ArgumentParser
|
||||
|
||||
public extension CommandConfiguration {
|
||||
|
||||
/// Use `NodeBuilder` syntax for generating the abstract and discussion parameters.
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
16
Sources/CliDoc/ArgumentParserExtensions/Discussion.swift
Normal file
16
Sources/CliDoc/ArgumentParserExtensions/Discussion.swift
Normal file
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
14
Sources/CliDoc/Modifiers/AnyModifier.swift
Normal file
14
Sources/CliDoc/Modifiers/AnyModifier.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
public struct AnyNodeModifier: NodeModifier {
|
||||
@usableFromInline
|
||||
let modifier: any NodeModifier
|
||||
|
||||
@inlinable
|
||||
public init<N: NodeModifier>(_ modifier: N) {
|
||||
self.modifier = modifier
|
||||
}
|
||||
|
||||
@inlinable
|
||||
public func render(_ node: any NodeRepresentable) -> any NodeRepresentable {
|
||||
modifier.render(node)
|
||||
}
|
||||
}
|
||||
35
Sources/CliDoc/Modifiers/Color.swift
Normal file
35
Sources/CliDoc/Modifiers/Color.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
48
Sources/CliDoc/Modifiers/LabelStyle.swift
Normal file
48
Sources/CliDoc/Modifiers/LabelStyle.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
58
Sources/CliDoc/Modifiers/LabeledContentStyle.swift
Normal file
58
Sources/CliDoc/Modifiers/LabeledContentStyle.swift
Normal file
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
44
Sources/CliDoc/Modifiers/Repeating.swift
Normal file
44
Sources/CliDoc/Modifiers/Repeating.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
41
Sources/CliDoc/Modifiers/Style.swift
Normal file
41
Sources/CliDoc/Modifiers/Style.swift
Normal file
@@ -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) }
|
||||
}
|
||||
}
|
||||
26
Sources/CliDoc/Node.swift
Normal file
26
Sources/CliDoc/Node.swift
Normal file
@@ -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" }
|
||||
}
|
||||
}
|
||||
110
Sources/CliDoc/NodeBuilder.swift
Normal file
110
Sources/CliDoc/NodeBuilder.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
@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<N: NodeRepresentable>(
|
||||
first component: N
|
||||
) -> N {
|
||||
component
|
||||
}
|
||||
|
||||
public static func buildEither<N: NodeRepresentable>(
|
||||
second component: N
|
||||
) -> N {
|
||||
component
|
||||
}
|
||||
|
||||
public static func buildBlock<N: NodeRepresentable>(_ components: N...) -> _ManyNode {
|
||||
.init(components)
|
||||
}
|
||||
|
||||
// This breaks things ??
|
||||
// public static func buildExpression<N: NodeRepresentable>(_ expression: N) -> AnyNode {
|
||||
// expression.eraseToAnyNode()
|
||||
// }
|
||||
|
||||
public static func buildOptional<N: NodeRepresentable>(_ component: N?) -> _ConditionalNode<N, Text> {
|
||||
switch component {
|
||||
case let .some(node):
|
||||
return .first(node)
|
||||
case .none:
|
||||
return .second(.empty())
|
||||
}
|
||||
}
|
||||
|
||||
public static func buildFinalResult<N: NodeRepresentable>(_ component: N) -> N {
|
||||
component
|
||||
}
|
||||
|
||||
public static func buildLimitedAvailability<N: NodeRepresentable>(_ 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: any NodeRepresentable = "\n" as any NodeRepresentable
|
||||
) {
|
||||
// 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
|
||||
}
|
||||
|
||||
@inlinable
|
||||
public func render() -> String {
|
||||
nodes.map { $0.render() }
|
||||
.joined(separator: separator.render())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// swiftlint:enable type_name
|
||||
9
Sources/CliDoc/NodeModifier.swift
Normal file
9
Sources/CliDoc/NodeModifier.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
3
Sources/CliDoc/NodeRepresentable.swift
Normal file
3
Sources/CliDoc/NodeRepresentable.swift
Normal file
@@ -0,0 +1,3 @@
|
||||
public protocol NodeRepresentable: Sendable {
|
||||
func render() -> String
|
||||
}
|
||||
27
Sources/CliDoc/Nodes/AnyNode.swift
Normal file
27
Sources/CliDoc/Nodes/AnyNode.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
37
Sources/CliDoc/Nodes/Group.swift
Normal file
37
Sources/CliDoc/Nodes/Group.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
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: String = " ",
|
||||
@NodeBuilder _ build: () -> any NodeRepresentable
|
||||
) {
|
||||
self.init(separator: separator.eraseToAnyNode(), node: build())
|
||||
}
|
||||
|
||||
@inlinable
|
||||
public func render() -> String {
|
||||
node.render()
|
||||
}
|
||||
}
|
||||
45
Sources/CliDoc/Nodes/Header.swift
Normal file
45
Sources/CliDoc/Nodes/Header.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
@preconcurrency import Rainbow
|
||||
|
||||
public struct Header: NodeRepresentable, @unchecked Sendable {
|
||||
|
||||
@usableFromInline
|
||||
let node: any NodeRepresentable
|
||||
|
||||
@usableFromInline
|
||||
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]
|
||||
) {
|
||||
self.color = color
|
||||
self.node = Text(text)
|
||||
self.styles = styles
|
||||
}
|
||||
|
||||
@inlinable
|
||||
public func render() -> String {
|
||||
var node = node
|
||||
if let color {
|
||||
node = node.color(color)
|
||||
}
|
||||
if let styles {
|
||||
node = node.style(styles)
|
||||
}
|
||||
return node.render()
|
||||
}
|
||||
}
|
||||
46
Sources/CliDoc/Nodes/LabeledContent.swift
Normal file
46
Sources/CliDoc/Nodes/LabeledContent.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
30
Sources/CliDoc/Nodes/Section.swift
Normal file
30
Sources/CliDoc/Nodes/Section.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
41
Sources/CliDoc/Nodes/ShellCommand.swift
Normal file
41
Sources/CliDoc/Nodes/ShellCommand.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
6
Sources/CliDoc/Nodes/String+NodeRepresentable.swift
Normal file
6
Sources/CliDoc/Nodes/String+NodeRepresentable.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
extension String: NodeRepresentable {
|
||||
@inlinable
|
||||
public func render() -> String {
|
||||
self
|
||||
}
|
||||
}
|
||||
32
Sources/CliDoc/Nodes/Text.swift
Normal file
32
Sources/CliDoc/Nodes/Text.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
||||
public extension NodeRepresentable {
|
||||
|
||||
static func empty() -> Self where Self == Text {
|
||||
Text("")
|
||||
}
|
||||
|
||||
static func newLine(count: Int = 1) -> Self where Self == AnyNode {
|
||||
"\n".repeating(count).eraseToAnyNode()
|
||||
}
|
||||
|
||||
static func space(count: Int = 1) -> Self where Self == AnyNode {
|
||||
" ".repeating(count).eraseToAnyNode()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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: (
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -83,7 +104,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,13 +120,10 @@ 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 +137,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 +179,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 +204,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 +233,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 +248,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 +264,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 +310,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 +323,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
|
||||
}
|
||||
}
|
||||
|
||||
120
Sources/hpa/Internal/Discussion+playbook.swift
Normal file
120
Sources/hpa/Internal/Discussion+playbook.swift
Normal file
@@ -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.
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
129
Tests/CliDocTests/CliDocTests.swift
Normal file
129
Tests/CliDocTests/CliDocTests.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
@_spi(Internal) import CliDoc
|
||||
@preconcurrency import Rainbow
|
||||
import Testing
|
||||
import XCTest
|
||||
|
||||
final class CliDocTests: XCTestCase {
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
Rainbow.outputTarget = .console
|
||||
Rainbow.enabled = true
|
||||
}
|
||||
|
||||
func testStringChecks() {
|
||||
let expected = "Foo".green.bold
|
||||
|
||||
XCTAssert("Foo".green.bold == expected)
|
||||
XCTAssert(expected != "Foo")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func testHeader() {
|
||||
let header = Header("Foo")
|
||||
let expected = "\("Foo".yellow.bold)"
|
||||
XCTAssert(header.render() == expected)
|
||||
|
||||
let header2 = Header {
|
||||
"Foo".yellow.bold
|
||||
}
|
||||
XCTAssert(header2.render() == expected)
|
||||
}
|
||||
|
||||
func testGroup() {
|
||||
let group = Group {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user