feat: Moves core functionality into it's own library.

This commit is contained in:
2024-12-05 09:59:30 -05:00
parent 33356d8648
commit e7bbbec7c2
24 changed files with 464 additions and 387 deletions

View File

@@ -5,6 +5,7 @@ import PackageDescription
let package = Package(
name: "swift-cli-doc",
products: [
.library(name: "CliDocCore", targets: ["CliDocCore"]),
.library(name: "CliDoc", targets: ["CliDoc"])
],
dependencies: [
@@ -12,14 +13,25 @@ let package = Package(
],
targets: [
.target(
name: "CliDoc",
name: "CliDocCore",
dependencies: [
.product(name: "Rainbow", package: "Rainbow")
]
),
.testTarget(
name: "CliDocCoreTests",
dependencies: ["CliDocCore"]
),
.target(
name: "CliDoc",
dependencies: [
"CliDocCore",
.product(name: "Rainbow", package: "Rainbow")
]
),
.testTarget(
name: "CliDocTests",
dependencies: ["CliDoc"]
dependencies: ["CliDocCore", "CliDoc"]
)
]
)

View File

@@ -1,46 +0,0 @@
public struct Section<Header: TextNode, Content: TextNode, Footer: TextNode>: TextNode {
@usableFromInline
let header: Header
@usableFromInline
let content: Content
@usableFromInline
let footer: Footer
@inlinable
public init(
@TextBuilder header: () -> Header,
@TextBuilder content: () -> Content,
@TextBuilder footer: () -> Footer
) {
self.header = header()
self.content = content()
self.footer = footer()
}
public var body: some TextNode {
style(.default)
}
}
public extension Section where Footer == Empty {
@inlinable
init(
@TextBuilder header: () -> Header,
@TextBuilder content: () -> Content
) {
self.init(header: header, content: content) { Empty() }
}
}
public extension Section where Header == Empty {
@inlinable
init(
@TextBuilder content: () -> Content,
@TextBuilder footer: () -> Footer
) {
self.init(header: { Empty() }, content: content, footer: footer)
}
}

View File

@@ -0,0 +1 @@
@_exported import CliDocCore

View File

@@ -1,35 +0,0 @@
import Rainbow
public extension TextNode {
func labelStyle<C: TextNode>(color: NamedColor? = nil, styles: [Style] = []) -> some TextNode where Self == Label<C> {
modifier(LabelStyle(color: color, styles: styles))
}
func labelStyle<C: TextNode>(color: NamedColor? = nil, styles: Style...) -> some TextNode where Self == Label<C> {
labelStyle(color: color, styles: styles)
}
}
public struct LabelStyle<C: TextNode>: NodeModifier {
@usableFromInline
let color: NamedColor?
@usableFromInline
let styles: [Style]
@usableFromInline
init(color: NamedColor? = nil, styles: [Style] = []) {
self.color = color
self.styles = styles
}
@inlinable
public func render(content: Label<C>) -> some TextNode {
var label: any TextNode = content.content
label = label.textStyle(styles)
if let color {
label = label.color(color)
}
return Label { label.eraseToAnyTextNode() }
}
}

View File

@@ -1,30 +0,0 @@
import Rainbow
public struct NoteStyleConfiguration {
let label: any TextNode
let content: any TextNode
}
public extension Note {
func noteStyle<S: NoteStyleModifier>(_ modifier: S) -> some TextNode {
modifier.render(content: .init(label: label, content: content))
}
}
public protocol NoteStyleModifier: NodeModifier where Content == NoteStyleConfiguration {}
public extension NoteStyleModifier where Self == DefaultNoteStyle {
static var `default`: Self {
DefaultNoteStyle()
}
}
public struct DefaultNoteStyle: NoteStyleModifier {
public func render(content: NoteStyleConfiguration) -> some TextNode {
HStack {
content.label.color(.yellow).textStyle(.bold)
content.content
}
}
}

View File

@@ -1,37 +0,0 @@
public extension Section {
@inlinable
func style<S: SectionStyle>(_ style: S) -> some TextNode {
style.render(content: .init(header: header, content: content, footer: footer))
}
}
public struct SectionConfiguration {
public let header: any TextNode
public let content: any TextNode
public let footer: any TextNode
@usableFromInline
init(header: any TextNode, content: any TextNode, footer: any TextNode) {
self.header = header
self.content = content
self.footer = footer
}
}
public protocol SectionStyle: NodeModifier where Content == SectionConfiguration {}
public extension SectionStyle where Self == DefaultSectionStyle {
static var `default`: Self { DefaultSectionStyle() }
}
public struct DefaultSectionStyle: SectionStyle {
public func render(content: SectionConfiguration) -> some TextNode {
VStack {
content.header
content.content
content.footer
}
}
}

View File

@@ -1,28 +0,0 @@
import Rainbow
public struct ShellCommandConfiguration {
let symbol: any TextNode
let content: any TextNode
}
public extension ShellCommand {
func style<S: ShellCommandStyle>(_ style: S) -> some TextNode {
style.render(content: .init(symbol: symbol, content: content))
}
}
public protocol ShellCommandStyle: NodeModifier where Self.Content == ShellCommandConfiguration {}
public extension ShellCommandStyle where Self == DefaultShellCommandStyle {
static var `default`: Self { DefaultShellCommandStyle() }
}
public struct DefaultShellCommandStyle: ShellCommandStyle {
public func render(content: ShellCommandConfiguration) -> some TextNode {
HStack {
content.symbol
content.content.textStyle(.italic)
}
}
}

View File

@@ -57,25 +57,6 @@ public struct ExampleSectionConfiguration {
}
}
// MARK: - Style
public extension ExampleSection {
func style<S: ExampleSectionStyle>(_ style: S) -> some TextNode {
style.render(content: configuration)
}
func exampleStyle<S: ExampleStyle>(_ style: S) -> some TextNode {
DefaultExamplesStyle(exampleStyle: style).render(content: configuration)
}
}
extension Array where Element == ExampleSection.Example {
func exampleStyle<S: ExampleStyle>(_ style: S) -> some TextNode {
style.render(content: .init(examples: self))
}
}
public struct ExampleConfiguration {
@usableFromInline
let examples: [ExampleSection.Example]
@@ -86,12 +67,42 @@ public struct ExampleConfiguration {
}
}
// MARK: - Style
public protocol ExampleSectionStyle: NodeModifier where Content == ExampleSectionConfiguration {}
public protocol ExampleStyle: NodeModifier where Content == ExampleConfiguration {}
public extension ExampleSectionStyle where Self == DefaultExamplesStyle {
static func `default`(exampleStyle: any ExampleStyle = .default) -> Self {
DefaultExamplesStyle(exampleStyle: exampleStyle)
public extension ExampleSection {
@inlinable
func style<S: ExampleSectionStyle>(_ style: S) -> some TextNode {
style.render(content: configuration)
}
@inlinable
func exampleStyle<S: ExampleStyle>(_ style: S) -> some TextNode {
DefaultExampleSectionStyle(exampleStyle: style).render(content: configuration)
}
}
extension Array where Element == ExampleSection.Example {
@inlinable
func exampleStyle<S: ExampleStyle>(_ style: S) -> some TextNode {
style.render(content: .init(examples: self))
}
}
public extension ExampleSectionStyle {
@inlinable
static func `default`<S: ExampleStyle>(exampleStyle: S) -> DefaultExampleSectionStyle<S> {
DefaultExampleSectionStyle(exampleStyle: exampleStyle)
}
}
public extension ExampleSectionStyle where Self == DefaultExampleSectionStyle<DefaultExampleStyle> {
@inlinable
static func `default`() -> DefaultExampleSectionStyle<DefaultExampleStyle> {
DefaultExampleSectionStyle()
}
}
@@ -101,19 +112,21 @@ public extension ExampleStyle where Self == DefaultExampleStyle {
}
}
public struct DefaultExamplesStyle: ExampleSectionStyle {
public struct DefaultExampleSectionStyle<Style: ExampleStyle>: ExampleSectionStyle {
@usableFromInline
let exampleStyle: any ExampleStyle
let exampleStyle: Style
@inlinable
public init(exampleStyle: any ExampleStyle = .default) {
public init(exampleStyle: Style) {
self.exampleStyle = exampleStyle
}
@inlinable
public func render(content: ExampleSectionConfiguration) -> some TextNode {
VStack(spacing: 2) {
Section {
exampleStyle.render(content: .init(examples: content.examples))
} header: {
HStack {
content.title
.color(.yellow)
@@ -122,13 +135,20 @@ public struct DefaultExamplesStyle: ExampleSectionStyle {
content.label
.textStyle(.italic)
}
exampleStyle.render(content: .init(examples: content.examples))
}
}
}
public extension DefaultExampleSectionStyle where Style == DefaultExampleStyle {
@inlinable
init() {
self.init(exampleStyle: .default)
}
}
public struct DefaultExampleStyle: ExampleStyle {
@inlinable
public func render(content: ExampleConfiguration) -> some TextNode {
VStack(spacing: 2) {
content.examples.map { example in

View File

@@ -72,3 +72,34 @@ public extension Note where Label == String, Content == String {
self.init(label, content: content)
}
}
public struct NoteStyleConfiguration {
let label: any TextNode
let content: any TextNode
}
public extension Note {
func noteStyle<S: NoteStyleModifier>(_ modifier: S) -> some TextNode {
modifier.render(content: .init(label: label, content: content))
}
}
// MARK: - Style
public protocol NoteStyleModifier: NodeModifier where Content == NoteStyleConfiguration {}
public extension NoteStyleModifier where Self == DefaultNoteStyle {
static var `default`: Self {
DefaultNoteStyle()
}
}
public struct DefaultNoteStyle: NoteStyleModifier {
public func render(content: NoteStyleConfiguration) -> some TextNode {
HStack {
content.label.color(.yellow).textStyle(.bold)
content.content
}
}
}

View File

@@ -1,3 +1,5 @@
import Rainbow
public struct ShellCommand<Content: TextNode>: TextNode {
@usableFromInline
@@ -30,3 +32,32 @@ public extension ShellCommand where Content == String {
self.init(symbol: symbol) { content }
}
}
public struct ShellCommandConfiguration {
let symbol: any TextNode
let content: any TextNode
}
public extension ShellCommand {
func style<S: ShellCommandStyle>(_ style: S) -> some TextNode {
style.render(content: .init(symbol: symbol, content: content))
}
}
// MARK: - Style
public protocol ShellCommandStyle: NodeModifier where Self.Content == ShellCommandConfiguration {}
public extension ShellCommandStyle where Self == DefaultShellCommandStyle {
static var `default`: Self { DefaultShellCommandStyle() }
}
public struct DefaultShellCommandStyle: ShellCommandStyle {
public func render(content: ShellCommandConfiguration) -> some TextNode {
HStack {
content.symbol
content.content.textStyle(.italic)
}
}
}

View File

@@ -12,24 +12,8 @@ public protocol NodeModifier {
func render(content: Content) -> Body
}
public extension NodeModifier {
func concat<T: NodeModifier>(_ modifier: T) -> ConcatModifier<Self, T> {
return .init(firstModifier: self, secondModifier: modifier)
}
}
public struct ConcatModifier<M0: NodeModifier, M1: NodeModifier>: NodeModifier where M1.Content == M0.Body {
let firstModifier: M0
let secondModifier: M1
public func render(content: M0.Content) -> some TextNode {
let firstOutput = firstModifier.render(content: content)
return secondModifier.render(content: firstOutput)
}
}
public struct ModifiedNode<Content: TextNode, Modifier: NodeModifier> {
@usableFromInline
let content: Content
@@ -47,14 +31,10 @@ extension ModifiedNode: TextNode where Modifier.Content == Content {
public var body: some TextNode {
modifier.render(content: content)
}
@inlinable
func apply<M: NodeModifier>(_ modifier: M) -> ModifiedNode<Content, ConcatModifier<Modifier, M>> {
return .init(content: content, modifier: self.modifier.concat(modifier))
}
}
extension ModifiedNode: NodeRepresentable where Self: TextNode {
@inlinable
public func render() -> String {
body.render()
}

View File

@@ -0,0 +1,21 @@
/// A type-erased text node.
public struct AnyTextNode: TextNode {
@usableFromInline
let makeString: () -> String
@inlinable
public init<N: TextNode>(_ node: N) {
self.makeString = node.render
}
@inlinable
public var body: some TextNode { makeString() }
}
public extension TextNode {
@inlinable
func eraseToAnyTextNode() -> AnyTextNode {
AnyTextNode(self)
}
}

View File

@@ -0,0 +1,96 @@
// TODO: Add vertical spacing.
public struct Section<Header: TextNode, Content: TextNode, Footer: TextNode>: TextNode {
@usableFromInline
let header: Header
@usableFromInline
let content: Content
@usableFromInline
let footer: Footer
@inlinable
public init(
@TextBuilder content: () -> Content,
@TextBuilder header: () -> Header,
@TextBuilder footer: () -> Footer
) {
self.header = header()
self.content = content()
self.footer = footer()
}
public var body: some TextNode {
style(.default)
}
}
public extension Section where Footer == Empty {
@inlinable
init(
@TextBuilder content: () -> Content,
@TextBuilder header: () -> Header
) {
self.init(content: content, header: header) { Empty() }
}
}
public extension Section where Header == Empty {
@inlinable
init(
@TextBuilder content: () -> Content,
@TextBuilder footer: () -> Footer
) {
self.init(content: content, header: { Empty() }, footer: footer)
}
}
public extension Section where Header == Empty, Footer == Empty {
@inlinable
init(
@TextBuilder content: () -> Content
) {
self.init(content: content, header: { Empty() }, footer: { Empty() })
}
}
// MARK: - Style
public extension Section {
@inlinable
func style<S: SectionStyle>(_ style: S) -> some TextNode {
style.render(content: .init(header: header, content: content, footer: footer))
}
}
public struct SectionConfiguration {
public let header: any TextNode
public let content: any TextNode
public let footer: any TextNode
@usableFromInline
init(header: any TextNode, content: any TextNode, footer: any TextNode) {
self.header = header
self.content = content
self.footer = footer
}
}
public protocol SectionStyle: NodeModifier where Content == SectionConfiguration {}
public extension SectionStyle where Self == DefaultSectionStyle {
static var `default`: Self { DefaultSectionStyle() }
}
public struct DefaultSectionStyle: SectionStyle {
public func render(content: SectionConfiguration) -> some TextNode {
VStack(spacing: 2) {
content.header
content.content
content.footer
}
}
}

View File

@@ -17,6 +17,8 @@ public extension TextNode {
}
}
// MARK: - String
extension String: NodeRepresentable {
public func render() -> String {
self
@@ -29,45 +31,37 @@ extension String: TextNode {
}
}
public struct AnyTextNode: TextNode {
let makeString: () -> String
public init<N: TextNode>(_ node: N) {
self.makeString = node.render
}
public var body: some TextNode { makeString() }
}
public extension TextNode {
func eraseToAnyTextNode() -> AnyTextNode {
AnyTextNode(self)
}
}
// MARK: - Optional
extension Optional: TextNode where Wrapped: TextNode {
@TextBuilder
public var body: some TextNode {
guard let node = self else { return "".eraseToAnyTextNode() }
return node.eraseToAnyTextNode()
switch self {
case let .some(node):
node
case .none:
Empty()
}
}
}
extension Optional: NodeRepresentable where Wrapped: NodeRepresentable {
extension Optional: NodeRepresentable where Wrapped: NodeRepresentable, Wrapped: TextNode {
public func render() -> String {
guard let node = self else { return "" }
return node.render()
body.render()
}
}
// MARK: - Array
extension Array: TextNode where Element: TextNode {
public var body: some TextNode {
NodeContainer(nodes: self)
}
}
extension Array: NodeRepresentable where Element: NodeRepresentable {
extension Array: NodeRepresentable where Element: NodeRepresentable, Element: TextNode {
public func render() -> String {
map { $0.render() }.joined()
body.render()
}
}

View File

@@ -0,0 +1,136 @@
@testable import CliDocCore
@preconcurrency import Rainbow
import Testing
@Suite("CliDocCore Tests")
struct CliDocCoreTests {
// Ensure that rainbow is setup, for test comparisons to work properly.
let setupRainbow: Bool = {
Rainbow.enabled = true
Rainbow.outputTarget = .console
return true
}()
@Test
func testGroup() {
#expect(setupRainbow)
let group = Group {
"foo"
"bar"
if setupRainbow {
"baz".color(.blue)
}
}
#expect(group.render() == "foobar\("baz".blue)")
}
@Test
func testHStack() {
#expect(setupRainbow)
let stack = HStack {
"foo"
"bar"
}
#expect(stack.render() == "foo bar")
}
@Test
func testVStack() {
#expect(setupRainbow)
let stack = VStack {
"foo"
"bar"
}
#expect(stack.render() == """
foo
bar
""")
}
@Test
func testOptionalTextNode() {
let someNode = String?.some("string")
#expect(someNode.body.render() == "string")
#expect(someNode.render() == "string")
let noneNode = String?.none
#expect(noneNode.body.render() == "")
#expect(noneNode.render() == "")
}
@Test
func testArrayTextNode() {
let array = ["foo", " ", "bar"]
#expect(array.body.render() == "foo bar")
#expect(array.render() == "foo bar")
}
@Test(
arguments: SectionArg.arguments
)
func testSection(arg: SectionArg) {
#expect(setupRainbow)
printIfNotEqual(arg.section.render(), arg.expected)
#expect(arg.section.render() == arg.expected)
}
struct SectionArg: @unchecked Sendable {
let section: any TextNode
let expected: String
static var arguments: [Self] {
[
.init(
section: Section {
"Content"
} header: {
"Header"
} footer: {
"Footer"
},
expected: """
Header
Content
Footer
"""
),
.init(
section: Section {
"Content"
} footer: {
"Footer"
},
expected: """
Content
Footer
"""
),
.init(
section: Section {
"Content"
} header: {
"Header"
},
expected: """
Header
Content
"""
)
]
}
}
}
@discardableResult
func printIfNotEqual(_ lhs: String, _ rhs: String) -> Bool {
guard lhs == rhs else {
print(lhs)
print(rhs)
return false
}
return true
}

View File

@@ -1,154 +1,79 @@
@testable import CliDoc
@testable import CliDocCore
@preconcurrency import Rainbow
import Testing
// Ensure that rainbow is setup, for test comparisons to work properly.
let setupRainbow: Bool = {
Rainbow.enabled = true
Rainbow.outputTarget = .console
return true
}()
@Suite("CliDoc tests")
struct CliDocTests {
// Ensure that rainbow is setup, for test comparisons to work properly.
let setupRainbow: Bool = {
Rainbow.enabled = true
Rainbow.outputTarget = .console
return true
}()
@Test
func testGroup() {
#expect(setupRainbow)
let group = Group {
"foo"
"bar"
@Test
func testNote() {
#expect(setupRainbow)
let note = Note(content: "Some note.")
let expected = """
\("NOTE:".yellow.bold) Some note.
"""
#expect(note.render() == expected)
}
#expect(group.render() == "foobar")
}
@Test
func testHStack() {
#expect(setupRainbow)
let stack = HStack {
"foo"
"bar"
@Test
func testExamples() {
#expect(setupRainbow)
let examples = ExampleSection(
"Examples:",
label: "Some common usage examples.",
examples: [("First", "ls -lah"), ("Second", "find . -name foo")]
)
let expected = """
\("Examples:".yellow.bold)\(" ")\("Some common usage examples.".italic)
\("First".green.bold)
$ \("ls -lah".italic)
\("Second".green.bold)
$ \("find . -name foo".italic)
"""
let result = printIfNotEqual(examples.render(), expected)
#expect(result)
}
#expect(stack.render() == "foo bar")
}
@Test
func testVStack() {
#expect(setupRainbow)
let stack = VStack {
"foo"
"bar"
}
#expect(stack.render() == """
foo
bar
""")
}
@Test
func testExamplesWithCustomExampleOnlyStyle() {
#expect(setupRainbow)
let examples = ExampleSection(
"Examples:",
label: "Some common usage examples.",
examples: [("First", "ls -lah"), ("Second", "find . -name foo")]
)
// .exampleStyle(CustomExampleOnlyStyle())
@Test
func testNote() {
#expect(setupRainbow)
let note = Note(content: "Some note.")
let expected = """
\("NOTE:".yellow.bold) Some note.
"""
#expect(note.render() == expected)
}
let expected = """
\("Examples:".applyingStyle(.bold).applyingColor(.yellow)) \("Some common usage examples.".italic)
@Test
func testExamples() {
#expect(setupRainbow)
let examples = ExampleSection(
"Examples:",
label: "Some common usage examples.",
examples: [("First", "ls -lah"), ("Second", "find . -name foo")]
)
\("First".red)
$ \("ls -lah".italic)
let expected = """
\("Examples:".yellow.bold)\(" ")\("Some common usage examples.".italic)
\("Second".red)
$ \("find . -name foo".italic)
"""
let result = printIfNotEqual(
examples.exampleStyle(CustomExampleOnlyStyle()).render(),
expected
)
#expect(result)
\("First".green.bold)
$ \("ls -lah".italic)
\("Second".green.bold)
$ \("find . -name foo".italic)
"""
let result = printIfNotEqual(examples.render(), expected)
#expect(result)
}
@Test
func testExamplesWithCustomExampleOnlyStyle() {
#expect(setupRainbow)
let examples = ExampleSection(
"Examples:",
label: "Some common usage examples.",
examples: [("First", "ls -lah"), ("Second", "find . -name foo")]
)
.exampleStyle(CustomExampleOnlyStyle())
let expected = """
\("Examples:".applyingStyle(.bold).applyingColor(.yellow)) \("Some common usage examples.".italic)
\("First".red)
$ \("ls -lah".italic)
\("Second".red)
$ \("find . -name foo".italic)
"""
let result = printIfNotEqual(examples.render(), expected)
#expect(result)
}
@Test(
arguments: SectionArg.arguments
)
func testSection(arg: SectionArg) {
#expect(setupRainbow)
printIfNotEqual(arg.section.render(), arg.expected)
#expect(arg.section.render() == arg.expected)
}
struct SectionArg: @unchecked Sendable {
let section: any TextNode
let expected: String
static var arguments: [Self] {
[
.init(
section: Section {
"Header"
} content: {
"Content"
} footer: {
"Footer"
},
expected: """
Header
Content
Footer
"""
),
.init(
section: Section {
"Content"
} footer: {
"Footer"
},
expected: """
Content
Footer
"""
),
.init(
section: Section {
"Header"
} content: {
"Content"
},
expected: """
Header
Content
"""
)
]
let result2 = printIfNotEqual(
examples.style(.custom).render(),
expected
)
#expect(result2)
}
}
@@ -162,6 +87,12 @@ func printIfNotEqual(_ lhs: String, _ rhs: String) -> Bool {
return true
}
extension ExampleSectionStyle where Self == DefaultExampleSectionStyle<CustomExampleOnlyStyle> {
static var custom: Self {
.default(exampleStyle: CustomExampleOnlyStyle())
}
}
struct CustomExampleOnlyStyle: ExampleStyle {
func render(content: ExampleConfiguration) -> some TextNode {
VStack(spacing: 2) {