Skip to content
Merged
32 changes: 32 additions & 0 deletions .github/workflows/generator-unit-test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Generator Unit Test
on:
workflow_call:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-generator-unit-test
cancel-in-progress: true
jobs:
generator-unit-test:
name: Generator Unit Test
runs-on: ubuntu-22.04
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
toolchain: [latest]
steps:
- name: Install Swift
uses: vapor/swiftly-action@v0.2
with:
toolchain: ${{ matrix.toolchain }}
env:
SWIFTLY_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout
uses: actions/checkout@v4.2.2
- name: Resolve Swift dependencies
run: swift package resolve
working-directory: ./Generator
- name: Run Unit Tests
run: swift test --parallel
working-directory: ./Generator
4 changes: 4 additions & 0 deletions .github/workflows/pull-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ jobs:
unit-test:
name: Unit Test
uses: ./.github/workflows/unit-test.yaml

generator-unit-test:
name: Generator Unit Test
uses: ./.github/workflows/generator-unit-test.yaml
9 changes: 8 additions & 1 deletion Generator/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,14 @@ let package = Package(
.product(name: "Yams", package: "Yams"),
.product(name: "ZIPFoundation", package: "ZIPFoundation"),
]
)
),
.testTarget(
name: "GeneratorTests",
dependencies: [
"Generator",
.product(name: "Yams", package: "Yams"),
]
),
],
swiftLanguageModes: [.v6]
)
116 changes: 91 additions & 25 deletions Generator/Sources/FileRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,43 +15,109 @@
protocol FileRenderer {
var targetDirectory: String { get }
var fileNamePrefix: String { get }
var context: Context { get }

func renderFile(_ namespace: Namespace) throws -> String

/// Converts an attribute ID to a Swift member path based on the type structure of the renderer.
/// Example: "foo.bar_baz" -> "OTelAttribute.foo.barBaz"
func attributeIDToSwiftMemberPath(_ attributeID: String) throws -> String
}

func renderDocs(_ attribute: Attribute) -> String {
var result = "`\(attribute.id)`"
if let brief = attribute.brief?.trimmingCharacters(in: .whitespacesAndNewlines), !brief.isEmpty {
result.append(": \(brief)")
}
/// Contains top-level information about the rendering execution
struct Context {
let rootNamespace: Namespace
}

result.append("\n\n- Stability: \(attribute.stability)")
if let attributeType = attribute.type as? Attribute.EnumType {
result.append("\n- Type: enum")
for member in attributeType.members {
result.append("\n - `\(member.value)`")
if let brief = member.brief?.trimmingCharacters(in: .whitespacesAndNewlines), !brief.isEmpty {
result.append(": \(brief)")
}
extension FileRenderer {
func renderDocs(_ attribute: Attribute) -> String {
var result = "`\(attribute.id)`"
if let brief = attribute.brief?.trimmingCharacters(in: .whitespacesAndNewlines), !brief.isEmpty {
result.append(": \(brief)")
}
} else {
result.append("\n- Type: \(attribute.type)")
}
if let examples = attribute.examples {
if examples.count == 1 {
result.append("\n- Example: `\(examples[0])`")

result.append("\n\n- Stability: \(attribute.stability)")
if let attributeType = attribute.type as? Attribute.EnumType {
result.append("\n- Type: enum")
for member in attributeType.members {
result.append("\n - `\(member.value)`")
if let brief = member.brief?.trimmingCharacters(in: .whitespacesAndNewlines), !brief.isEmpty {
result.append(": \(brief)")
}
}
} else {
result.append("\n- Examples:")
for example in examples {
result.append("\n - `\(example)`")
result.append("\n- Type: \(attribute.type)")
}
if let examples = attribute.examples {
if examples.count == 1 {
result.append("\n- Example: `\(examples[0])`")
} else {
result.append("\n- Examples:")
for example in examples {
result.append("\n - `\(example)`")
}
}
}

if let note = attribute.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
result.append("\n\n\(note)")
}

return result.prefixLines(with: "/// ")
}

if let note = attribute.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
result.append("\n\n\(note)")
/// Render the `@available` Swift attribute for a deprecated OTel attribute
///
/// - Parameters:
/// - deprecated: The `Deprecated` enum that describes the deprecation
/// - extendedTypeName: The name of the type being extended with this attribute (i.e. OTelAttribute, or SpanAttributes). Used in the `renamed` argument, if applicable.
/// - Returns: A string representing the `@available` attribute for Swift
func renderDeprecatedAttribute(_ deprecated: Deprecated) -> String {
var result = "@available(*, deprecated"
switch deprecated {
case let .renamed(renamed_to, note):
if let renamedTo = try? attributeIDToSwiftMemberPath(renamed_to) {
result.append(", renamed: \"\(renamedTo)\"")
}
if let note = note?.trimmingCharacters(in: .whitespacesAndNewlines) {
result.append(", message: \"\(note)\"")
}
case let .obsoleted(note):
var message = "Obsoleted"
if let note = note?.trimmingCharacters(in: .whitespacesAndNewlines) {
message.append(": \(note)")
}
result.append(", message: \"\(message)\"")
case let .uncategorized(note):
if let note = note?.trimmingCharacters(in: .whitespacesAndNewlines) {
result.append(", message: \"\(note)\"")
}
}
result.append(")")
return result
}
}

return result.prefixLines(with: "/// ")
/// Return the standard Swift member name for an attribute, on the final type that contains the attribute.
/// Example: "foo.bar_baz" -> "barBaz"
/// The provided namespace must be the parent of the attribute ID.
func attributeMemberName(_ attributeID: String, _ namespace: Namespace) throws -> String {
let attributePath = attributeID.split(separator: ".")
guard attributePath.count > 1 else {
throw GeneratorError.invalidAttributeID(attributeID)
}
guard namespace.name == attributePath[attributePath.count - 2] else {
throw GeneratorError.renderingError(
"The provided namespace `\(namespace.name)` is not the parent of the attribute `\(attributeID)`"
)
}
let attributeName = attributePath[attributePath.count - 1]
var propertyName = nameGenerator.swiftMemberName(for: String(attributeName))
// In the case where we have both an attribute and a namespace overlapping (deployment.environment & deployment.environment.name), the attribute gets an underscore in order to avoid name clobbering.
if namespace.subNamespaces[propertyName] != nil {
propertyName = "_\(propertyName)"
}
return propertyName
}

extension String {
Expand Down
47 changes: 23 additions & 24 deletions Generator/Sources/FileRenderers/OTelAttributeRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ struct OTelAttributeRenderer: FileRenderer {
let targetDirectory = "AttributeNames"
let fileNamePrefix = "OTelAttribute+"

let context: Context

func renderFile(_ namespace: Namespace) throws -> String {
try """
extension OTelAttribute {
Expand Down Expand Up @@ -51,38 +53,35 @@ struct OTelAttributeRenderer: FileRenderer {

private func renderAttribute(_ attribute: Attribute, _ namespace: Namespace, indent: Int) throws -> String {
var result = renderDocs(attribute)
if let deprecatedMessage = attribute.deprecated?.note?.trimmingCharacters(in: .whitespacesAndNewlines) {
result.append("\n@available(*, deprecated, message: \"\(deprecatedMessage)\")")
if let deprecated = attribute.deprecated {
result.append("\n" + renderDeprecatedAttribute(deprecated))
}
try result.append(
"\npublic static let \(swiftOTelAttributePropertyName(attribute, namespace)) = \"\(attribute.id)\""
"\npublic static let \(attributeMemberName(attribute.id, namespace)) = \"\(attribute.id)\""
)

return result.indent(by: indent)
}
}

// Returns the Swift path to any input attribute based on the input namespace
func swiftOTelAttributePath(_ attribute: Attribute, _ namespace: Namespace) throws -> String {
try swiftOTelNamespacePath(namespace) + "." + swiftOTelAttributePropertyName(attribute, namespace)
}
func attributeIDToSwiftMemberPath(_ attributeID: String) throws -> String {
var path = ["OTelAttribute"]

private func swiftOTelNamespacePath(_ namespace: Namespace) -> String {
var path = ["OTelAttribute"]
for subNamespaceName in namespace.id.split(separator: ".") {
path.append(nameGenerator.swiftMemberName(for: String(subNamespaceName)))
}
return path.joined(separator: ".")
}
let components = attributeID.split(separator: ".").map { String($0) }
guard components.count > 1 else {
path.append(nameGenerator.swiftMemberName(for: components[0]))
return path.joined(separator: ".")
}

private func swiftOTelAttributePropertyName(_ attribute: Attribute, _ namespace: Namespace) throws -> String {
guard let attributeName = attribute.id.split(separator: ".").last else {
throw GeneratorError.attributeNameNotFound(namespace.id)
}
var propertyName = nameGenerator.swiftMemberName(for: String(attributeName))
// In the case where we have both an attribute and a namespace overlapping (deployment.environment & deployment.environment.name), the attribute gets an underscore in order to avoid name clobbering.
if namespace.subNamespaces[propertyName] != nil {
propertyName = "_\(propertyName)"
// Walk the namespace tree, appending each name. Record the namespace so we can resolve the attribute name at the end.
var namespace = context.rootNamespace
for subNamespaceName in components[0..<components.count - 1] {
guard let nextNamespace = namespace.subNamespaces[subNamespaceName] else {
throw GeneratorError.namespaceNameNotFound(subNamespaceName)
}
path.append(nextNamespace.memberName)
namespace = nextNamespace
}
try path.append(attributeMemberName(attributeID, namespace))
return path.joined(separator: ".")
}
return propertyName
}
66 changes: 44 additions & 22 deletions Generator/Sources/FileRenderers/SpanAttributeRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ struct SpanAttributeRenderer: FileRenderer {
let targetDirectory = "Tracing"
let fileNamePrefix = "SpanAttributes+"

let context: Context

func renderFile(_ namespace: Namespace) throws -> String {
try """
#if Tracing
Expand All @@ -31,6 +33,28 @@ struct SpanAttributeRenderer: FileRenderer {
"""
}

func attributeIDToSwiftMemberPath(_ attributeID: String) throws -> String {
var path = ["SpanAttributes"]

let components = attributeID.split(separator: ".").map { String($0) }
guard components.count > 1 else {
path.append(nameGenerator.swiftMemberName(for: components[0]))
return path.joined(separator: ".")
}

// Walk the namespace tree, appending each name. Record the namespace so we can resolve the attribute name at the end.
var namespace = context.rootNamespace
for subNamespaceName in components[0..<components.count - 1] {
guard let nextNamespace = namespace.subNamespaces[subNamespaceName] else {
throw GeneratorError.namespaceNameNotFound(subNamespaceName)
}
path.append(nextNamespace.memberName)
namespace = nextNamespace
}
try path.append(attributeMemberName(attributeID, namespace))
return path.joined(separator: ".")
}

private func renderNamespace(_ namespace: Namespace, inSpanNamespace: Bool = false, indent: Int) throws -> String {
let propertyName = namespace.memberName
let structName = nameGenerator.swiftTypeName(for: "\(namespace.name)Attributes")
Expand Down Expand Up @@ -64,7 +88,7 @@ struct SpanAttributeRenderer: FileRenderer {
try result.append(
"\n\n"
+ templateAttributes.sorted { $0.id < $1.id }.map { attribute in
try renderTemplateAttribute(attribute, indent: 4)
try renderTemplateAttribute(attribute, namespace, indent: 4)
}.joined(separator: "\n\n")
)
}
Expand Down Expand Up @@ -102,19 +126,17 @@ struct SpanAttributeRenderer: FileRenderer {
guard let attributeName = attribute.id.split(separator: ".").last else {
throw GeneratorError.attributeNameNotFound(attribute.id)
}
var propertyName = String(attributeName)
// In the case where we have both an attribute and a namespace overlapping (deployment.environment & deployment.environment.name), the attribute gets an underscore in order to avoid name clobbering.
if namespace.subNamespaces[propertyName] != nil {
propertyName = "_\(propertyName)"
}
propertyName = nameGenerator.swiftMemberName(for: propertyName)
let propertyName = try attributeMemberName(attribute.id, namespace)

var result = renderDocs(attribute)
if let deprecatedMessage = attribute.deprecated?.note?.trimmingCharacters(in: .whitespacesAndNewlines) {
result.append("\n@available(*, deprecated, message: \"\(deprecatedMessage)\")")
if let deprecated = attribute.deprecated {
result.append("\n" + renderDeprecatedAttribute(deprecated))
}

let swiftType: String
// Use the OTelAttributeRenderer to get the Swift path for the attribute.
let otelAttributeFileRenderer = OTelAttributeRenderer(context: context)
let otelAttributePath = try otelAttributeFileRenderer.attributeIDToSwiftMemberPath(attribute.id)
if let type = attribute.type as? Attribute.StandardType {
switch type {
case .boolean: swiftType = "Bool"
Expand All @@ -128,13 +150,13 @@ struct SpanAttributeRenderer: FileRenderer {
default:
throw SpanAttributeRendererError.invalidStandardAttributeType(attribute.type)
}
try result.append(
"\npublic var \(propertyName): Self.Key<\(swiftType)> { .init(name: \(swiftOTelAttributePath(attribute, namespace))) }"
result.append(
"\npublic var \(propertyName): Self.Key<\(swiftType)> { .init(name: \(otelAttributePath)) }"
)
} else if let type = attribute.type as? Attribute.EnumType {
let enumTypeName = "\(nameGenerator.swiftTypeName(for: "\(attributeName)Enum"))"
try result.append(
"\npublic var \(propertyName): Self.Key<\(enumTypeName)> { .init(name: \(swiftOTelAttributePath(attribute, namespace))) }"
result.append(
"\npublic var \(propertyName): Self.Key<\(enumTypeName)> { .init(name: \(otelAttributePath)) }"
)

// Enum types are not represented as Swift enums to avoid breaking changes when new enum values are added.
Expand Down Expand Up @@ -169,11 +191,11 @@ struct SpanAttributeRenderer: FileRenderer {
return result.indent(by: indent)
}

private func renderTemplateAttribute(_ attribute: Attribute, indent: Int) throws -> String {
private func renderTemplateAttribute(_ attribute: Attribute, _ namespace: Namespace, indent: Int) throws -> String {
guard let attributeName = attribute.id.split(separator: ".").last else {
throw GeneratorError.attributeNameNotFound(attribute.id)
}
let swiftName = nameGenerator.swiftMemberName(for: String(attributeName))
let swiftName = try attributeMemberName(attribute.id, namespace)
let structName = nameGenerator.swiftTypeName(for: "\(attributeName)Attributes")

let valueType: String
Expand Down Expand Up @@ -216,24 +238,24 @@ struct SpanAttributeRenderer: FileRenderer {
}

public mutating func set(_ key: String, to value: \(valueType)) {
let attributeId = self.attributeId(forKey: key)
self.attributes[attributeId] = value
let attributeID = self.attributeID(forKey: key)
self.attributes[attributeID] = value
}

private func attributeId(forKey key: String) -> String {
var attributeId = "\(attribute.id)."
private func attributeID(forKey key: String) -> String {
var attributeID = "\(attribute.id)."

for index in key.indices {
let character = key[index]

if character == "-" {
attributeId.append("_")
attributeID.append("_")
} else {
attributeId.append(character.lowercased())
attributeID.append(character.lowercased())
}
}

return attributeId
return attributeID
}
}
"""
Expand Down
Loading