feat: Node type done and working, which is used for styling and rendering documentation strings

This commit is contained in:
2024-11-30 17:42:13 -05:00
parent 4b84c19198
commit 81925a95d6
3 changed files with 174 additions and 38 deletions

View File

@@ -27,7 +27,10 @@ extension CommandConfiguration {
abstract: abstract, abstract: abstract,
discussion: [.note(label: "Most options are not required if you have a configuration file setup.")] discussion: [.note(label: "Most options are not required if you have a configuration file setup.")]
+ examples.nodes(parentCommand) + examples.nodes(parentCommand)
+ [.seeAlso(label: "Ansible playbook options.", command: "ansible-playbook")] + [
.seeAlso(label: "Ansible playbook options.", command: "ansible-playbook"),
.important(label: Constants.importantExtraArgsNote)
]
) )
} }
} }
@@ -37,7 +40,7 @@ func createAbstract(_ string: String) -> String {
} }
struct Discussion { struct Discussion {
var nodes: [Node] let nodes: [Node]
init(usesExtraArgs: Bool = true, _ nodes: Node...) { init(usesExtraArgs: Bool = true, _ nodes: Node...) {
self.init(nodes: nodes, usesExtraArgs: usesExtraArgs) self.init(nodes: nodes, usesExtraArgs: usesExtraArgs)
@@ -52,57 +55,188 @@ struct Discussion {
if usesExtraArgs { if usesExtraArgs {
if let lastExampleIndex = nodes.lastIndex(where: \.isExampleNode) { if let lastExampleIndex = nodes.lastIndex(where: \.isExampleNode) {
let last = nodes[lastExampleIndex] guard case let .example(.container(exampleNodes, separator)) = nodes[lastExampleIndex] else {
if case let .example(_, example, parent) = last { // this should never happen.
nodes.insert( print(nodes[lastExampleIndex])
.example(label: "Passing extra args.", example: example, parentCommand: parent), fatalError()
at: nodes.index(after: lastExampleIndex)
)
} }
// Get the last element of the example container, which will be the
// text for the example.
guard let exampleNode = exampleNodes.last else {
print(exampleNodes)
fatalError()
}
// 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
)),
at: nodes.index(after: lastExampleIndex)
)
} }
} }
self.nodes = nodes self.nodes = nodes
} }
func render() -> String { func render() -> String { nodes.render() }
nodes.map { $0.render() }.joined(separator: "\n")
}
} }
enum Node: Equatable { enum Node {
case note(header: String = "NOTE:", label: String, appendNewLine: Bool = true)
case example(label: String, example: String, parentCommand: String?)
case seeAlso(label: String, command: String, appendHelpToCommand: Bool = true)
static var exampleHeading: Self { .note(header: "Examples:", label: "Some common examples.", appendNewLine: false) } /// A container that holds onto other nodes to be rendered.
indirect case container(_ nodes: [Node], separator: String = "\n\n")
/// A container to identify example nodes, so some other nodes can get injected
/// when this is rendered.
///
/// NOTE: Things blow up if this is not used correctly, at least when using the `Discussion`
/// to render the nodes, so this is not safe to create aside from the static methods that
/// create an example node. If not using in the context of a `Discussion` then it's fine to
/// use, as it doesn't do anything except stand as an type identifier.
indirect case example(_ node: Node)
/// A root node that renders the given string without modification.
case text(_ text: String)
static func styledText(_ text: String, color: NamedColor? = nil, styles: [Style]? = nil) -> Self {
var string = text
if let color {
string = string.applyingColor(color)
}
if let styles {
for style in styles {
string = string.applyingStyle(style)
}
}
return .text(string)
}
static func styledText(_ text: String, color: NamedColor? = nil, style: Style) -> Self {
.styledText(text, color: color, styles: [style])
}
static func header(_ 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: String, label: Node, inline: Bool = false) -> Self {
.section(header: .header(header), content: label, inline: inline)
}
static func important(label: String) -> Self {
.section(
header: .text("IMPORTANT NOTE:".red.bold.underline),
content: .text(label.red.italic),
inline: true
)
}
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 shellCommand(_ text: String, symbol: String = " $") -> Self {
.text("\(symbol) \(text)")
}
static func seeAlso(label: String, command: String, appendHelpToCommand: Bool = true) -> Self {
.container(
.container([.header("See Also:"), .text(label.italic)], separator: " "),
.shellCommand("\(appendHelpToCommand ? command + " --help" : command)")
)
}
static var exampleHeading: Self {
.section(header: "Examples:", label: .text("Some common examples."), inline: true)
}
var isExampleNode: Bool { var isExampleNode: Bool {
if case .example = self { return true } if case .example = self { return true }
return false return false
} }
/// Renders an example usage of the command.
static func example(label: String, example: String, parentCommand: String?) -> Self {
let string: String
if let parent = parentCommand {
string = "\(Constants.appName) \(parent) \(example)"
} else {
string = "\(Constants.appName) \(example)"
}
return .example(.section(
header: .exampleLabel(label),
content: .shellCommand(string, symbol: " $")
))
}
static func exampleLabel(_ label: String) -> Node {
.text(" \(label.green.italic)")
}
func render() -> String { func render() -> String {
switch self { switch self {
case let .note(header, note, appendNewLine): case let .container(nodes, separator):
return "\(header.yellow) \(note.italic)\(appendNewLine ? "\n" : "")" return nodes.render(separator: separator)
case let .example(label: label, example: example, parentCommand: parent): case let .text(text):
return """ return text
\(label.green.italic) case let .example(node):
$ \(Constants.appName) \(parent ?? "")\(parent != nil ? " \(example)" : example) return node.render()
"""
case let .seeAlso(label: label, command: command, appendHelpToCommand: appendHelp):
return """
\("See Also:".yellow) \(label.italic)
$ \(command)\(appendHelp ? " --help" : "")
"""
} }
} }
} }
extension Node: ExpressibleByStringLiteral {
typealias StringLiteralType = String
init(stringLiteral value: String) {
self = .text(value)
}
}
extension Node: ExpressibleByArrayLiteral {
typealias ArrayLiteralElement = Node
init(arrayLiteral elements: Node...) {
self = .container(elements)
}
}
private extension Array where Element == (label: String, example: String) { private extension Array where Element == (label: String, example: String) {
func nodes(_ parentCommand: String?) -> [Node] { func nodes(_ parentCommand: String?) -> [Node] {
map { .example(label: $0.label, example: $0.example, parentCommand: parentCommand) } map { .example(label: $0.label, example: $0.example, parentCommand: parentCommand) }
} }
} }
private extension Array where Element == Node {
func render(separator: String = "\n\n") -> 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
// rendered node.
var string = $0.render()[...]
while string.hasSuffix("\n") {
string = string.dropLast()
}
return String(string)
}.joined(separator: separator)
}
}

View File

@@ -6,12 +6,8 @@ enum Constants {
static let playbookFileName = "main.yml" static let playbookFileName = "main.yml"
static let inventoryFileName = "inventory.ini" static let inventoryFileName = "inventory.ini"
static let importantExtraArgsNote = """ static let importantExtraArgsNote = """
Any extra arguments to pass to the underlying command invocation have to
\("IMPORTANT NOTE".bold.underline): \(""" be at the end with `--` before any arguments otherwise there will
Any extra arguments to pass to the underlying command invocation have to be an "Unkown option" error. See examples above.
be at the end with `--` before any arguments otherwise there will """
be an "Unkown option" error. See examples above.
""".italic
)
""".red
} }

View File

@@ -7,6 +7,12 @@ struct VaultCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
commandName: commandName, commandName: commandName,
abstract: createAbstract("Vault commands."), abstract: createAbstract("Vault commands."),
discussion: Discussion(
.text("""
Allows you to run `ansible-vault` commands on your project or project-template.
"""),
.seeAlso(label: "Ansible Vault", command: "ansible-vault")
).render(),
subcommands: [ subcommands: [
EncryptCommand.self, DecryptCommand.self EncryptCommand.self, DecryptCommand.self
] ]