Skip to content

Commit 603970c

Browse files
committed
Start of refactor of SafeNameGenerator
1 parent 4d4e9b6 commit 603970c

File tree

2 files changed

+176
-91
lines changed

2 files changed

+176
-91
lines changed

Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/SwiftSafeNames.swift renamed to Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/SafeNameGenerator.swift

Lines changed: 168 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -26,28 +26,72 @@ struct SwiftNameOptions {
2626
static let noncapitalized = SwiftNameOptions(isCapitalized: false)
2727
}
2828

29-
extension String {
29+
/// Computes a string sanitized to be usable as a Swift identifier in various contexts.
30+
protocol SafeNameGenerator {
3031

31-
/// Returns a string sanitized to be usable as a Swift identifier.
32-
///
33-
/// See the proposal SOAR-0001 for details.
34-
///
35-
/// For example, the string `$nake…` would be returned as `_dollar_nake_x2026_`, because
36-
/// both the dollar and ellipsis sign are not valid characters in a Swift identifier.
37-
/// So, it replaces such characters with their html entity equivalents or unicode hex representation,
38-
/// in case it's not present in the `specialCharsMap`. It marks this replacement with `_` as a delimiter.
39-
///
40-
/// In addition to replacing illegal characters, it also
41-
/// ensures that the identifier starts with a letter and not a number.
42-
func safeForSwiftCode_defensive(options: SwiftNameOptions) -> String {
43-
guard !isEmpty else { return "_empty" }
32+
/// Returns a string sanitized to be usable as a Swift identifier in a general context.
33+
/// - Parameters:
34+
/// - documentedName: The input unsanitized string from the OpenAPI document.
35+
/// - options: Additional context for how the sanitized string will be used.
36+
/// - Returns: The sanitized string.
37+
func swiftName(for documentedName: String, options: SwiftNameOptions) -> String
38+
39+
/// Returns a string sanitized to be usable as a Swift identifier for the provided content type.
40+
/// - Parameter contentType: The content type for which to compute a Swift identifier.
41+
/// - Returns: A Swift identifier for the provided content type.
42+
func contentTypeSwiftName(for contentType: ContentType) -> String
43+
}
44+
45+
extension SafeNameGenerator {
46+
47+
/// Returns a Swift identifier override for the provided content type.
48+
/// - Parameter contentType: A content type.
49+
/// - Returns: A Swift identifer for the content type, or nil if the provided content type doesn't
50+
/// have an override.
51+
func swiftNameOverride(for contentType: ContentType) -> String? {
52+
let rawContentType = contentType.lowercasedTypeSubtypeAndParameters
53+
switch rawContentType {
54+
case "application/json": return "json"
55+
case "application/x-www-form-urlencoded": return "urlEncodedForm"
56+
case "multipart/form-data": return "multipartForm"
57+
case "text/plain": return "plainText"
58+
case "*/*": return "any"
59+
case "application/xml": return "xml"
60+
case "application/octet-stream": return "binary"
61+
case "text/html": return "html"
62+
case "application/yaml": return "yaml"
63+
case "text/csv": return "csv"
64+
case "image/png": return "png"
65+
case "application/pdf": return "pdf"
66+
case "image/jpeg": return "jpeg"
67+
default:
68+
return nil
69+
}
70+
}
71+
}
72+
73+
/// Returns a string sanitized to be usable as a Swift identifier.
74+
///
75+
/// See the proposal SOAR-0001 for details.
76+
///
77+
/// For example, the string `$nake…` would be returned as `_dollar_nake_x2026_`, because
78+
/// both the dollar and ellipsis sign are not valid characters in a Swift identifier.
79+
/// So, it replaces such characters with their html entity equivalents or unicode hex representation,
80+
/// in case it's not present in the `specialCharsMap`. It marks this replacement with `_` as a delimiter.
81+
///
82+
/// In addition to replacing illegal characters, it also
83+
/// ensures that the identifier starts with a letter and not a number.
84+
struct DefensiveSafeNameGenerator: SafeNameGenerator {
85+
86+
func swiftName(for documentedName: String, options: SwiftNameOptions) -> String {
87+
guard !documentedName.isEmpty else { return "_empty" }
4488

4589
let firstCharSet: CharacterSet = .letters.union(.init(charactersIn: "_"))
4690
let numbers: CharacterSet = .decimalDigits
4791
let otherCharSet: CharacterSet = .alphanumerics.union(.init(charactersIn: "_"))
4892

4993
var sanitizedScalars: [Unicode.Scalar] = []
50-
for (index, scalar) in unicodeScalars.enumerated() {
94+
for (index, scalar) in documentedName.unicodeScalars.enumerated() {
5195
let allowedSet = index == 0 ? firstCharSet : otherCharSet
5296
let outScalar: Unicode.Scalar
5397
if allowedSet.contains(scalar) {
@@ -70,7 +114,7 @@ extension String {
70114
sanitizedScalars.append(outScalar)
71115
}
72116

73-
let validString = String(UnicodeScalarView(sanitizedScalars))
117+
let validString = String(String.UnicodeScalarView(sanitizedScalars))
74118

75119
// Special case for a single underscore.
76120
// We can't add it to the map as its a valid swift identifier in other cases.
@@ -80,19 +124,73 @@ extension String {
80124
return "_\(validString)"
81125
}
82126

83-
/// Returns a string sanitized to be usable as a Swift identifier, and tries to produce UpperCamelCase
84-
/// or lowerCamelCase string, the casing is controlled using the provided options.
85-
///
86-
/// If the string contains any illegal characters, falls back to the behavior
87-
/// matching `safeForSwiftCode_defensive`.
127+
func contentTypeSwiftName(for contentType: ContentType) -> String {
128+
if let common = swiftNameOverride(for: contentType) {
129+
return common
130+
}
131+
let safedType = swiftName(for: contentType.originallyCasedType, options: .noncapitalized)
132+
let safedSubtype = swiftName(for: contentType.originallyCasedSubtype, options: .noncapitalized)
133+
let componentSeparator = "_"
134+
let prefix = "\(safedType)\(componentSeparator)\(safedSubtype)"
135+
let params = contentType.lowercasedParameterPairs
136+
guard !params.isEmpty else { return prefix }
137+
let safedParams =
138+
params.map { pair in
139+
pair.split(separator: "=")
140+
.map { component in
141+
swiftName(for: String(component), options: .noncapitalized)
142+
}
143+
.joined(separator: componentSeparator)
144+
}
145+
.joined(separator: componentSeparator)
146+
return prefix + componentSeparator + safedParams
147+
}
148+
149+
/// A list of Swift keywords.
88150
///
89-
/// Check out [SOAR-0013](https://swiftpackageindex.com/apple/swift-openapi-generator/documentation/swift-openapi-generator/soar-0013) for details.
90-
func safeForSwiftCode_idiomatic(options: SwiftNameOptions) -> String {
151+
/// Copied from SwiftSyntax/TokenKind.swift
152+
private static let keywords: Set<String> = [
153+
"associatedtype", "class", "deinit", "enum", "extension", "func", "import", "init", "inout", "let", "operator",
154+
"precedencegroup", "protocol", "struct", "subscript", "typealias", "var", "fileprivate", "internal", "private",
155+
"public", "static", "defer", "if", "guard", "do", "repeat", "else", "for", "in", "while", "return", "break",
156+
"continue", "fallthrough", "switch", "case", "default", "where", "catch", "throw", "as", "Any", "false", "is",
157+
"nil", "rethrows", "super", "self", "Self", "true", "try", "throws", "yield", "String", "Error", "Int", "Bool",
158+
"Array", "Type", "type", "Protocol", "await",
159+
]
160+
161+
/// A map of ASCII printable characters to their HTML entity names. Used to reduce collisions in generated names.
162+
private static let specialCharsMap: [Unicode.Scalar: String] = [
163+
" ": "space", "!": "excl", "\"": "quot", "#": "num", "$": "dollar", "%": "percnt", "&": "amp", "'": "apos",
164+
"(": "lpar", ")": "rpar", "*": "ast", "+": "plus", ",": "comma", "-": "hyphen", ".": "period", "/": "sol",
165+
":": "colon", ";": "semi", "<": "lt", "=": "equals", ">": "gt", "?": "quest", "@": "commat", "[": "lbrack",
166+
"\\": "bsol", "]": "rbrack", "^": "hat", "`": "grave", "{": "lcub", "|": "verbar", "}": "rcub", "~": "tilde",
167+
]
168+
}
169+
170+
extension SafeNameGenerator where Self == DefensiveSafeNameGenerator {
171+
static var defensive: DefensiveSafeNameGenerator {
172+
DefensiveSafeNameGenerator()
173+
}
174+
}
175+
176+
/// Returns a string sanitized to be usable as a Swift identifier, and tries to produce UpperCamelCase
177+
/// or lowerCamelCase string, the casing is controlled using the provided options.
178+
///
179+
/// If the string contains any illegal characters, falls back to the behavior
180+
/// matching `safeForSwiftCode_defensive`.
181+
///
182+
/// Check out [SOAR-0013](https://swiftpackageindex.com/apple/swift-openapi-generator/documentation/swift-openapi-generator/soar-0013) for details.
183+
struct IdiomaticSafeNameGenerator: SafeNameGenerator {
184+
185+
/// The defensive strategy to use as fallback.
186+
var defensive: DefensiveSafeNameGenerator
187+
188+
func swiftName(for documentedName: String, options: SwiftNameOptions) -> String {
91189
let capitalize = options.isCapitalized
92-
if isEmpty { return capitalize ? "_Empty_" : "_empty_" }
190+
if documentedName.isEmpty { return capitalize ? "_Empty_" : "_empty_" }
93191

94192
// Detect cases like HELLO_WORLD, sometimes used for constants.
95-
let isAllUppercase = allSatisfy {
193+
let isAllUppercase = documentedName.allSatisfy {
96194
// Must check that no characters are lowercased, as non-letter characters
97195
// don't return `true` to `isUppercase`.
98196
!$0.isLowercase
@@ -105,7 +203,7 @@ extension String {
105203
// 4. In the middle: drop ["{", "}"] -> replace with ""
106204

107205
var buffer: [Character] = []
108-
buffer.reserveCapacity(count)
206+
buffer.reserveCapacity(documentedName.count)
109207
enum State: Equatable {
110208
case modifying
111209
case preFirstWord
@@ -115,8 +213,8 @@ extension String {
115213
case waitingForWordStarter
116214
}
117215
var state: State = .preFirstWord
118-
for index in self[...].indices {
119-
let char = self[index]
216+
for index in documentedName[...].indices {
217+
let char = documentedName[index]
120218
let _state = state
121219
state = .modifying
122220
switch _state {
@@ -158,7 +256,7 @@ extension String {
158256
buffer.append(char)
159257
context.isAccumulatingInitialUppercase = false
160258
} else {
161-
let suffix = suffix(from: self.index(after: index))
259+
let suffix = documentedName.suffix(from: documentedName.index(after: index))
162260
if suffix.count >= 2 {
163261
let next = suffix.first!
164262
let secondNext = suffix.dropFirst().first!
@@ -245,29 +343,51 @@ extension String {
245343
}
246344
precondition(state != .modifying, "Logic error in \(#function), string: '\(self)'")
247345
}
248-
return String(buffer).safeForSwiftCode_defensive(options: options)
346+
return defensive.swiftName(for: String(buffer), options: options)
347+
}
348+
349+
func contentTypeSwiftName(for contentType: ContentType) -> String {
350+
if let common = swiftNameOverride(for: contentType) {
351+
return common
352+
}
353+
let safedType = swiftName(for: contentType.originallyCasedType, options: .noncapitalized)
354+
let safedSubtype = swiftName(for: contentType.originallyCasedSubtype, options: .noncapitalized)
355+
let prettifiedSubtype = safedSubtype.uppercasingFirstLetter
356+
let prefix = "\(safedType)\(prettifiedSubtype)"
357+
let params = contentType.lowercasedParameterPairs
358+
guard !params.isEmpty else { return prefix }
359+
let safedParams =
360+
params.map { pair in
361+
pair.split(separator: "=")
362+
.map { component in
363+
let safedComponent = swiftName(for: String(component), options: .noncapitalized)
364+
return safedComponent.uppercasingFirstLetter
365+
}
366+
.joined()
367+
}
368+
.joined()
369+
return prefix + safedParams
249370
}
250371

251372
/// A list of word separator characters for the idiomatic naming strategy.
252373
private static let wordSeparators: Set<Character> = ["_", "-", " ", "/", "+"]
374+
}
253375

254-
/// A list of Swift keywords.
255-
///
256-
/// Copied from SwiftSyntax/TokenKind.swift
257-
private static let keywords: Set<String> = [
258-
"associatedtype", "class", "deinit", "enum", "extension", "func", "import", "init", "inout", "let", "operator",
259-
"precedencegroup", "protocol", "struct", "subscript", "typealias", "var", "fileprivate", "internal", "private",
260-
"public", "static", "defer", "if", "guard", "do", "repeat", "else", "for", "in", "while", "return", "break",
261-
"continue", "fallthrough", "switch", "case", "default", "where", "catch", "throw", "as", "Any", "false", "is",
262-
"nil", "rethrows", "super", "self", "Self", "true", "try", "throws", "yield", "String", "Error", "Int", "Bool",
263-
"Array", "Type", "type", "Protocol", "await",
264-
]
376+
extension SafeNameGenerator where Self == DefensiveSafeNameGenerator {
377+
static var idiomatic: IdiomaticSafeNameGenerator {
378+
IdiomaticSafeNameGenerator(defensive: .defensive)
379+
}
380+
}
265381

266-
/// A map of ASCII printable characters to their HTML entity names. Used to reduce collisions in generated names.
267-
private static let specialCharsMap: [Unicode.Scalar: String] = [
268-
" ": "space", "!": "excl", "\"": "quot", "#": "num", "$": "dollar", "%": "percnt", "&": "amp", "'": "apos",
269-
"(": "lpar", ")": "rpar", "*": "ast", "+": "plus", ",": "comma", "-": "hyphen", ".": "period", "/": "sol",
270-
":": "colon", ";": "semi", "<": "lt", "=": "equals", ">": "gt", "?": "quest", "@": "commat", "[": "lbrack",
271-
"\\": "bsol", "]": "rbrack", "^": "hat", "`": "grave", "{": "lcub", "|": "verbar", "}": "rcub", "~": "tilde",
272-
]
382+
extension String {
383+
384+
@available(*, deprecated)
385+
func safeForSwiftCode_defensive(options: SwiftNameOptions) -> String {
386+
DefensiveSafeNameGenerator().swiftName(for: self, options: options)
387+
}
388+
389+
@available(*, deprecated)
390+
func safeForSwiftCode_idiomatic(options: SwiftNameOptions) -> String {
391+
IdiomaticSafeNameGenerator(defensive: DefensiveSafeNameGenerator()).swiftName(for: self, options: options)
392+
}
273393
}

Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeAssigner.swift

Lines changed: 8 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -512,51 +512,16 @@ struct TypeAssigner {
512512
///
513513
/// - Parameter contentType: The content type for which to compute the name.
514514
/// - Returns: A Swift-safe identifier representing the name of the content enum case.
515+
@available(*, deprecated)
515516
func contentSwiftName(_ contentType: ContentType) -> String {
516-
let rawContentType = contentType.lowercasedTypeSubtypeAndParameters
517-
switch rawContentType {
518-
case "application/json": return "json"
519-
case "application/x-www-form-urlencoded": return "urlEncodedForm"
520-
case "multipart/form-data": return "multipartForm"
521-
case "text/plain": return "plainText"
522-
case "*/*": return "any"
523-
case "application/xml": return "xml"
524-
case "application/octet-stream": return "binary"
525-
case "text/html": return "html"
526-
case "application/yaml": return "yaml"
527-
case "text/csv": return "csv"
528-
case "image/png": return "png"
529-
case "application/pdf": return "pdf"
530-
case "image/jpeg": return "jpeg"
531-
default:
532-
let safedType = context.asSwiftSafeName(contentType.originallyCasedType, .noncapitalized)
533-
let safedSubtype = context.asSwiftSafeName(contentType.originallyCasedSubtype, .noncapitalized)
534-
let componentSeparator: String
535-
let capitalizeNonFirstWords: Bool
536-
switch context.namingStrategy {
537-
case .defensive:
538-
componentSeparator = "_"
539-
capitalizeNonFirstWords = false
540-
case .idiomatic:
541-
componentSeparator = ""
542-
capitalizeNonFirstWords = true
543-
}
544-
let prettifiedSubtype = capitalizeNonFirstWords ? safedSubtype.uppercasingFirstLetter : safedSubtype
545-
let prefix = "\(safedType)\(componentSeparator)\(prettifiedSubtype)"
546-
let params = contentType.lowercasedParameterPairs
547-
guard !params.isEmpty else { return prefix }
548-
let safedParams =
549-
params.map { pair in
550-
pair.split(separator: "=")
551-
.map { component in
552-
let safedComponent = context.asSwiftSafeName(String(component), .noncapitalized)
553-
return capitalizeNonFirstWords ? safedComponent.uppercasingFirstLetter : safedComponent
554-
}
555-
.joined(separator: componentSeparator)
556-
}
557-
.joined(separator: componentSeparator)
558-
return prefix + componentSeparator + safedParams
517+
let nameGenerator: any SafeNameGenerator
518+
switch context.namingStrategy {
519+
case .defensive:
520+
nameGenerator = .defensive
521+
case .idiomatic:
522+
nameGenerator = .idiomatic
559523
}
524+
return nameGenerator.contentTypeSwiftName(for: contentType)
560525
}
561526
}
562527

0 commit comments

Comments
 (0)