4aee51762a
Extends symbol extraction to mixed Swift+C-family packages via clangd multiplexing through sourcekit-lsp: new CFamilyDeclarationParser, CompilationDatabaseWriter, ObjectiveCShimWriter, SourceLanguage detection, PathFilter, and ShapeSnapshot, plus a shared DeclarationParser contract and build/test scripts.
610 lines
22 KiB
Swift
610 lines
22 KiB
Swift
import Foundation
|
|
|
|
struct ShapeSnapshot: Codable {
|
|
let root: String
|
|
let files: [String]
|
|
let types: [TypeShape]
|
|
|
|
static func build(symbolTable: SymbolTable, packageRoot: String, pathFilter: String?, excludePaths: [String]) -> ShapeSnapshot {
|
|
let filteredFiles = symbolTable.fileTargets.keys.sorted().filter { filePath in
|
|
pathIsIncluded(filePath, includePath: pathFilter, excludePaths: excludePaths)
|
|
}
|
|
let relevantSymbols = filteredFiles.flatMap { symbolTable.fileSymbols[$0] ?? [] }
|
|
let relevantExtensions = filteredFiles.flatMap { symbolTable.fileExtensions[$0] ?? [] }
|
|
|
|
var ownerNames = Set<String>()
|
|
for sym in relevantSymbols where sym.typeName.isEmpty && !sym.kind.isMemberKind {
|
|
ownerNames.insert(sym.name)
|
|
}
|
|
for ext in relevantExtensions {
|
|
ownerNames.insert(ext.baseType)
|
|
}
|
|
|
|
let types = ownerNames.sorted().map { ownerName -> TypeShape in
|
|
let declarations = relevantSymbols
|
|
.filter { $0.typeName.isEmpty && $0.name == ownerName && !$0.kind.isMemberKind }
|
|
.sorted { lhs, rhs in
|
|
if lhs.filePath != rhs.filePath { return lhs.filePath < rhs.filePath }
|
|
if lhs.selectionLine != rhs.selectionLine { return lhs.selectionLine < rhs.selectionLine }
|
|
return lhs.selectionChar < rhs.selectionChar
|
|
}
|
|
let members = relevantSymbols.filter { $0.typeName == ownerName }
|
|
let extensions = relevantExtensions.filter { $0.baseType == ownerName }
|
|
|
|
let kind = declarations.first?.kind.rawValue
|
|
?? (extensions.isEmpty ? SymbolKind.unknown.rawValue : SymbolKind.extension.rawValue)
|
|
let superclass = declarations.lazy.compactMap(\.superclass).first
|
|
|
|
var conformances = Set<String>()
|
|
for decl in declarations {
|
|
decl.conformances.forEach { conformances.insert($0) }
|
|
}
|
|
for ext in extensions {
|
|
ext.conformances.forEach { conformances.insert($0) }
|
|
}
|
|
|
|
let nestedTypes = members
|
|
.filter { !$0.kind.isMemberKind }
|
|
.map { "\($0.kind.rawValue):\($0.name)" }
|
|
.sorted()
|
|
|
|
let properties = members
|
|
.filter { $0.kind == .property }
|
|
.map(normalizedPropertyKey)
|
|
.sorted()
|
|
|
|
let methods = members
|
|
.filter { $0.kind == .function }
|
|
.map(normalizedCallableKey)
|
|
.sorted()
|
|
|
|
let initializers = members
|
|
.filter { $0.kind == .initializer }
|
|
.map(normalizedCallableKey)
|
|
.sorted()
|
|
|
|
return TypeShape(
|
|
name: ownerName,
|
|
kind: kind,
|
|
superclass: superclass,
|
|
conformances: conformances.sorted(),
|
|
nestedTypes: nestedTypes,
|
|
properties: properties,
|
|
methods: methods,
|
|
initializers: initializers
|
|
)
|
|
}
|
|
|
|
let relFiles = filteredFiles.map { filePath in
|
|
filePath.hasPrefix(packageRoot + "/")
|
|
? String(filePath.dropFirst(packageRoot.count + 1))
|
|
: filePath
|
|
}
|
|
|
|
return ShapeSnapshot(root: packageRoot, files: relFiles, types: types)
|
|
}
|
|
}
|
|
|
|
struct TypeShape: Codable {
|
|
let name: String
|
|
let kind: String
|
|
let superclass: String?
|
|
let conformances: [String]
|
|
let nestedTypes: [String]
|
|
let properties: [String]
|
|
let methods: [String]
|
|
let initializers: [String]
|
|
}
|
|
|
|
struct ShapeDiff {
|
|
let baselineOnlyTypes: [String]
|
|
let candidateOnlyTypes: [String]
|
|
let changedTypes: [TypeShapeDiff]
|
|
|
|
var hasDifferences: Bool {
|
|
!baselineOnlyTypes.isEmpty || !candidateOnlyTypes.isEmpty || !changedTypes.isEmpty
|
|
}
|
|
|
|
static func compare(baseline: ShapeSnapshot, candidate: ShapeSnapshot) -> ShapeDiff {
|
|
let baselineByName = Dictionary(uniqueKeysWithValues: baseline.types.map { ($0.name, $0) })
|
|
let candidateByName = Dictionary(uniqueKeysWithValues: candidate.types.map { ($0.name, $0) })
|
|
|
|
let baselineNames = Set(baselineByName.keys)
|
|
let candidateNames = Set(candidateByName.keys)
|
|
|
|
let baselineOnlyTypes = baselineNames.subtracting(candidateNames).sorted()
|
|
let candidateOnlyTypes = candidateNames.subtracting(baselineNames).sorted()
|
|
|
|
let changedTypes: [TypeShapeDiff] = baselineNames.intersection(candidateNames).sorted().compactMap { name -> TypeShapeDiff? in
|
|
guard let baselineType = baselineByName[name], let candidateType = candidateByName[name] else { return nil }
|
|
return TypeShapeDiff.compare(name: name, baseline: baselineType, candidate: candidateType)
|
|
}
|
|
|
|
return ShapeDiff(
|
|
baselineOnlyTypes: baselineOnlyTypes,
|
|
candidateOnlyTypes: candidateOnlyTypes,
|
|
changedTypes: changedTypes
|
|
)
|
|
}
|
|
|
|
func render(baselineLabel: String, candidateLabel: String) -> String {
|
|
var lines: [String] = []
|
|
lines.append("Shape comparison")
|
|
lines.append("baseline: \(baselineLabel)")
|
|
lines.append("candidate: \(candidateLabel)")
|
|
|
|
if !hasDifferences {
|
|
lines.append("")
|
|
lines.append("No shape differences found.")
|
|
return lines.joined(separator: "\n") + "\n"
|
|
}
|
|
|
|
if !baselineOnlyTypes.isEmpty {
|
|
lines.append("")
|
|
lines.append("Only in baseline:")
|
|
for name in baselineOnlyTypes {
|
|
lines.append(" - \(name)")
|
|
}
|
|
}
|
|
|
|
if !candidateOnlyTypes.isEmpty {
|
|
lines.append("")
|
|
lines.append("Only in candidate:")
|
|
for name in candidateOnlyTypes {
|
|
lines.append(" - \(name)")
|
|
}
|
|
}
|
|
|
|
if !changedTypes.isEmpty {
|
|
lines.append("")
|
|
lines.append("Changed types:")
|
|
for diff in changedTypes {
|
|
lines.append(" \(diff.name)")
|
|
lines.append(contentsOf: diff.renderDetails().map { " " + $0 })
|
|
}
|
|
}
|
|
|
|
return lines.joined(separator: "\n") + "\n"
|
|
}
|
|
}
|
|
|
|
struct TypeShapeDiff {
|
|
let name: String
|
|
let kindMismatch: (String, String)?
|
|
let superclassMismatch: (String?, String?)?
|
|
let baselineOnlyConformances: [String]
|
|
let candidateOnlyConformances: [String]
|
|
let baselineOnlyNestedTypes: [String]
|
|
let candidateOnlyNestedTypes: [String]
|
|
let baselineOnlyProperties: [String]
|
|
let candidateOnlyProperties: [String]
|
|
let baselineOnlyMethods: [String]
|
|
let candidateOnlyMethods: [String]
|
|
let baselineOnlyInitializers: [String]
|
|
let candidateOnlyInitializers: [String]
|
|
|
|
var hasDifferences: Bool {
|
|
kindMismatch != nil ||
|
|
superclassMismatch != nil ||
|
|
!baselineOnlyConformances.isEmpty ||
|
|
!candidateOnlyConformances.isEmpty ||
|
|
!baselineOnlyNestedTypes.isEmpty ||
|
|
!candidateOnlyNestedTypes.isEmpty ||
|
|
!baselineOnlyProperties.isEmpty ||
|
|
!candidateOnlyProperties.isEmpty ||
|
|
!baselineOnlyMethods.isEmpty ||
|
|
!candidateOnlyMethods.isEmpty ||
|
|
!baselineOnlyInitializers.isEmpty ||
|
|
!candidateOnlyInitializers.isEmpty
|
|
}
|
|
|
|
static func compare(name: String, baseline: TypeShape, candidate: TypeShape) -> TypeShapeDiff? {
|
|
let diff = TypeShapeDiff(
|
|
name: name,
|
|
kindMismatch: baseline.kind == candidate.kind ? nil : (baseline.kind, candidate.kind),
|
|
superclassMismatch: baseline.superclass == candidate.superclass ? nil : (baseline.superclass, candidate.superclass),
|
|
baselineOnlyConformances: Set(baseline.conformances).subtracting(candidate.conformances).sorted(),
|
|
candidateOnlyConformances: Set(candidate.conformances).subtracting(baseline.conformances).sorted(),
|
|
baselineOnlyNestedTypes: Set(baseline.nestedTypes).subtracting(candidate.nestedTypes).sorted(),
|
|
candidateOnlyNestedTypes: Set(candidate.nestedTypes).subtracting(baseline.nestedTypes).sorted(),
|
|
baselineOnlyProperties: Set(baseline.properties).subtracting(candidate.properties).sorted(),
|
|
candidateOnlyProperties: Set(candidate.properties).subtracting(baseline.properties).sorted(),
|
|
baselineOnlyMethods: Set(baseline.methods).subtracting(candidate.methods).sorted(),
|
|
candidateOnlyMethods: Set(candidate.methods).subtracting(baseline.methods).sorted(),
|
|
baselineOnlyInitializers: Set(baseline.initializers).subtracting(candidate.initializers).sorted(),
|
|
candidateOnlyInitializers: Set(candidate.initializers).subtracting(baseline.initializers).sorted()
|
|
)
|
|
return diff.hasDifferences ? diff : nil
|
|
}
|
|
|
|
func renderDetails() -> [String] {
|
|
var lines: [String] = []
|
|
if let kindMismatch {
|
|
lines.append("kind: \(kindMismatch.0) -> \(kindMismatch.1)")
|
|
}
|
|
if let superclassMismatch {
|
|
lines.append("superclass: \(superclassMismatch.0 ?? "nil") -> \(superclassMismatch.1 ?? "nil")")
|
|
}
|
|
if !baselineOnlyConformances.isEmpty {
|
|
lines.append("missing conformances: \(baselineOnlyConformances.joined(separator: ", "))")
|
|
}
|
|
if !candidateOnlyConformances.isEmpty {
|
|
lines.append("extra conformances: \(candidateOnlyConformances.joined(separator: ", "))")
|
|
}
|
|
if !baselineOnlyNestedTypes.isEmpty {
|
|
lines.append("missing nested types: \(baselineOnlyNestedTypes.joined(separator: ", "))")
|
|
}
|
|
if !candidateOnlyNestedTypes.isEmpty {
|
|
lines.append("extra nested types: \(candidateOnlyNestedTypes.joined(separator: ", "))")
|
|
}
|
|
if !baselineOnlyProperties.isEmpty {
|
|
lines.append("missing properties: \(baselineOnlyProperties.joined(separator: ", "))")
|
|
}
|
|
if !candidateOnlyProperties.isEmpty {
|
|
lines.append("extra properties: \(candidateOnlyProperties.joined(separator: ", "))")
|
|
}
|
|
if !baselineOnlyMethods.isEmpty {
|
|
lines.append("missing methods: \(baselineOnlyMethods.joined(separator: ", "))")
|
|
}
|
|
if !candidateOnlyMethods.isEmpty {
|
|
lines.append("extra methods: \(candidateOnlyMethods.joined(separator: ", "))")
|
|
}
|
|
if !baselineOnlyInitializers.isEmpty {
|
|
lines.append("missing initializers: \(baselineOnlyInitializers.joined(separator: ", "))")
|
|
}
|
|
if !candidateOnlyInitializers.isEmpty {
|
|
lines.append("extra initializers: \(candidateOnlyInitializers.joined(separator: ", "))")
|
|
}
|
|
return lines
|
|
}
|
|
}
|
|
|
|
private extension SymbolKind {
|
|
var isMemberKind: Bool {
|
|
self == .property || self == .function || self == .initializer
|
|
}
|
|
}
|
|
|
|
private func normalizedPropertyKey(_ sym: SymbolInfo) -> String {
|
|
let name = canonicalIdentifier(sym.name)
|
|
if let returnType = sym.returnType, !returnType.isEmpty {
|
|
return "\(name):\(normalizeTypeString(returnType))"
|
|
}
|
|
return name
|
|
}
|
|
|
|
private func normalizedCallableKey(_ sym: SymbolInfo) -> String {
|
|
switch detectSignatureLanguage(sym.signature) {
|
|
case .swift:
|
|
return normalizedSwiftCallableKey(sym)
|
|
case .objectiveC:
|
|
return normalizedObjCCallableKey(sym)
|
|
case .cLike:
|
|
return normalizedCLikeCallableKey(sym)
|
|
}
|
|
}
|
|
|
|
private enum SignatureLanguage {
|
|
case swift
|
|
case objectiveC
|
|
case cLike
|
|
}
|
|
|
|
private func detectSignatureLanguage(_ signature: String) -> SignatureLanguage {
|
|
if signature.contains("func ") || signature.contains("init(") || signature.contains("init<") {
|
|
return .swift
|
|
}
|
|
if signature.hasPrefix("- ") || signature.hasPrefix("+ ") || signature.contains("@property") {
|
|
return .objectiveC
|
|
}
|
|
return .cLike
|
|
}
|
|
|
|
private func normalizedSwiftCallableKey(_ sym: SymbolInfo) -> String {
|
|
let signature = sym.signature.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let prefix = sym.kind == .initializer ? "init" : canonicalSwiftBaseName(sym.name)
|
|
guard let open = signature.firstIndex(of: "("),
|
|
let close = matchingParen(in: signature, open: open)
|
|
else {
|
|
return prefix
|
|
}
|
|
let params = String(signature[signature.index(after: open)..<close])
|
|
let labels = splitTopLevel(params, separator: ",").map { raw -> String in
|
|
let part = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let colon = part.firstIndex(of: ":") else { return "_" }
|
|
let labelPart = part[..<colon].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let tokens = labelPart.split(separator: " ")
|
|
guard let first = tokens.first else { return "_" }
|
|
let label = String(first)
|
|
return label == "_" ? "_" : label
|
|
}
|
|
let canonicalLabels = labels.map { $0 == "_" ? "_" : canonicalIdentifier($0) }
|
|
if canonicalLabels.isEmpty || (canonicalLabels.count == 1 && canonicalLabels[0].isEmpty) {
|
|
return prefix + "()"
|
|
}
|
|
return prefix + "(" + canonicalLabels.map { "\($0):" }.joined() + ")"
|
|
}
|
|
|
|
private func normalizedObjCCallableKey(_ sym: SymbolInfo) -> String {
|
|
let signature = sym.signature.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let close = signature.firstIndex(of: ")") else { return sym.name }
|
|
let tail = signature[signature.index(after: close)...].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !tail.contains(":") {
|
|
let name = tail.split(whereSeparator: \.isWhitespace).first.map(String.init) ?? sym.name
|
|
return name
|
|
}
|
|
|
|
let components = splitObjectiveCSelector(tail)
|
|
if components.isEmpty {
|
|
return canonicalObjectiveCZeroArgName(sym.name)
|
|
}
|
|
return canonicalObjectiveCCallableKey(components, kind: sym.kind)
|
|
}
|
|
|
|
private func normalizedCLikeCallableKey(_ sym: SymbolInfo) -> String {
|
|
let signature = sym.signature.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let open = signature.firstIndex(of: "(") else { return sym.name }
|
|
let beforeParen = signature[..<open].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let name = beforeParen.split(whereSeparator: \.isWhitespace).last.map(String.init) ?? sym.name
|
|
let params = signature[signature.index(after: open)..<(matchingParen(in: signature, open: open) ?? signature.endIndex)]
|
|
let arity = splitTopLevel(String(params), separator: ",")
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty && $0 != "void" }
|
|
.count
|
|
return arity == 0 ? "\(name)()" : "\(name)(\(String(repeating: "_:", count: arity)))"
|
|
}
|
|
|
|
private func splitObjectiveCSelector(_ tail: String) -> [String] {
|
|
var result: [String] = []
|
|
var segment = ""
|
|
var parenDepth = 0
|
|
var angleDepth = 0
|
|
|
|
for ch in tail {
|
|
switch ch {
|
|
case "(":
|
|
parenDepth += 1
|
|
case ")":
|
|
if parenDepth > 0 { parenDepth -= 1 }
|
|
case "<":
|
|
angleDepth += 1
|
|
case ">":
|
|
if angleDepth > 0 { angleDepth -= 1 }
|
|
case ":":
|
|
if parenDepth == 0 && angleDepth == 0 {
|
|
if let label = lastIdentifier(in: segment) {
|
|
result.append(label)
|
|
}
|
|
segment = ""
|
|
}
|
|
default:
|
|
segment.append(ch)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private func splitTopLevel(_ text: String, separator: Character) -> [String] {
|
|
var parts: [String] = []
|
|
var current = ""
|
|
var parenDepth = 0
|
|
var angleDepth = 0
|
|
var bracketDepth = 0
|
|
|
|
for ch in text {
|
|
switch ch {
|
|
case "(":
|
|
parenDepth += 1
|
|
case ")":
|
|
if parenDepth > 0 { parenDepth -= 1 }
|
|
case "<":
|
|
angleDepth += 1
|
|
case ">":
|
|
if angleDepth > 0 { angleDepth -= 1 }
|
|
case "[":
|
|
bracketDepth += 1
|
|
case "]":
|
|
if bracketDepth > 0 { bracketDepth -= 1 }
|
|
default:
|
|
break
|
|
}
|
|
|
|
if ch == separator && parenDepth == 0 && angleDepth == 0 && bracketDepth == 0 {
|
|
parts.append(current)
|
|
current = ""
|
|
} else {
|
|
current.append(ch)
|
|
}
|
|
}
|
|
|
|
if !current.isEmpty {
|
|
parts.append(current)
|
|
}
|
|
return parts
|
|
}
|
|
|
|
private func matchingParen(in text: String, open: String.Index) -> String.Index? {
|
|
var depth = 0
|
|
var index = open
|
|
while index < text.endIndex {
|
|
let ch = text[index]
|
|
if ch == "(" {
|
|
depth += 1
|
|
} else if ch == ")" {
|
|
depth -= 1
|
|
if depth == 0 { return index }
|
|
}
|
|
index = text.index(after: index)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func normalizeTypeString(_ type: String) -> String {
|
|
type
|
|
.replacingOccurrences(of: " ", with: "")
|
|
.replacingOccurrences(of: "?", with: "?")
|
|
}
|
|
|
|
private func lastIdentifier(in text: String) -> String? {
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
|
|
var end = trimmed.endIndex
|
|
while end > trimmed.startIndex {
|
|
let prev = trimmed.index(before: end)
|
|
let scalar = trimmed[prev]
|
|
if scalar.isLetter || scalar.isNumber || scalar == "_" {
|
|
break
|
|
}
|
|
end = prev
|
|
}
|
|
guard end > trimmed.startIndex else { return nil }
|
|
|
|
var start = end
|
|
while start > trimmed.startIndex {
|
|
let prev = trimmed.index(before: start)
|
|
let scalar = trimmed[prev]
|
|
if scalar.isLetter || scalar.isNumber || scalar == "_" {
|
|
start = prev
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
let identifier = String(trimmed[start..<end])
|
|
return identifier.isEmpty ? nil : identifier
|
|
}
|
|
|
|
private func canonicalSwiftBaseName(_ name: String) -> String {
|
|
if let open = name.firstIndex(of: "(") {
|
|
return canonicalIdentifier(String(name[..<open]))
|
|
}
|
|
return canonicalIdentifier(name)
|
|
}
|
|
|
|
private func canonicalObjectiveCZeroArgName(_ name: String) -> String {
|
|
let canonical = canonicalIdentifier(name)
|
|
if canonical.hasPrefix("get"), canonical.count > 3, let dropped = dropGetterPrefix(canonical) {
|
|
return dropped + "()"
|
|
}
|
|
return canonical + "()"
|
|
}
|
|
|
|
private func canonicalObjectiveCCallableKey(_ rawComponents: [String], kind: SymbolKind) -> String {
|
|
var components = rawComponents.map(normalizeObjectiveCSelectorComponent)
|
|
if let last = components.last, last == "error" {
|
|
components.removeLast()
|
|
}
|
|
|
|
guard let first = components.first else {
|
|
return kind == .initializer ? "init()" : canonicalIdentifier(rawComponents.first ?? "")
|
|
}
|
|
|
|
if kind == .initializer {
|
|
return canonicalObjectiveCInitializerKey(components)
|
|
}
|
|
|
|
if components.count == 1 {
|
|
let single = first
|
|
if single.hasSuffix("WithError"), let base = single.dropSuffix("WithError") {
|
|
if let getter = dropGetterPrefix(base) {
|
|
return getter + "()"
|
|
}
|
|
return base + "()"
|
|
}
|
|
if single.hasPrefix("set"), single.count > 3 {
|
|
return single + "(_:)"
|
|
}
|
|
return single + "(_:)"
|
|
}
|
|
|
|
if let (base, firstLabel) = splitWithLabel(first) {
|
|
let labels = [firstLabel] + Array(components.dropFirst())
|
|
return base + "(" + labels.map { "\($0):" }.joined() + ")"
|
|
}
|
|
|
|
return first + "(" + Array(components.dropFirst()).map { "\($0):" }.joined() + ")"
|
|
}
|
|
|
|
private func canonicalObjectiveCInitializerKey(_ components: [String]) -> String {
|
|
guard let first = components.first else { return "init()" }
|
|
if first == "init" && components.count == 1 {
|
|
return "init()"
|
|
}
|
|
if let suffix = first.dropPrefix("initWith"), !suffix.isEmpty {
|
|
let firstLabel = lowercasedFirst(suffix)
|
|
let remaining = [firstLabel] + Array(components.dropFirst())
|
|
return "init(" + remaining.map { "\($0):" }.joined() + ")"
|
|
}
|
|
if first == "init" {
|
|
return "init(" + Array(components.dropFirst()).map { "\($0):" }.joined() + ")"
|
|
}
|
|
return "init(" + components.map { "\($0):" }.joined() + ")"
|
|
}
|
|
|
|
private func splitWithLabel(_ value: String) -> (String, String)? {
|
|
guard let range = value.range(of: "With"),
|
|
range.lowerBound != value.startIndex,
|
|
range.upperBound != value.endIndex
|
|
else { return nil }
|
|
|
|
let base = String(value[..<range.lowerBound])
|
|
let label = lowercasedFirst(String(value[range.upperBound...]))
|
|
guard !base.isEmpty, !label.isEmpty else { return nil }
|
|
return (base, label)
|
|
}
|
|
|
|
private func dropGetterPrefix(_ value: String) -> String? {
|
|
guard let suffix = value.dropPrefix("get"), !suffix.isEmpty else { return nil }
|
|
return lowercasedFirst(suffix)
|
|
}
|
|
|
|
private func canonicalIdentifier(_ value: String) -> String {
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return trimmed }
|
|
if trimmed.contains("_") {
|
|
let parts = trimmed.split(separator: "_").map(String.init)
|
|
guard let first = parts.first else { return trimmed }
|
|
let rest = parts.dropFirst().map { uppercasedFirst($0) }
|
|
return ([lowercasedFirst(first)] + rest).joined()
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
private func normalizeObjectiveCSelectorComponent(_ value: String) -> String {
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return trimmed }
|
|
|
|
var candidate = trimmed
|
|
if let close = candidate.lastIndex(of: ")"), close < candidate.index(before: candidate.endIndex) {
|
|
candidate = String(candidate[candidate.index(after: close)...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
if candidate.contains(" ") {
|
|
candidate = candidate.split(whereSeparator: \.isWhitespace).last.map(String.init) ?? candidate
|
|
}
|
|
return canonicalIdentifier(candidate)
|
|
}
|
|
|
|
private func lowercasedFirst(_ value: String) -> String {
|
|
guard let first = value.first else { return value }
|
|
return String(first).lowercased() + value.dropFirst()
|
|
}
|
|
|
|
private func uppercasedFirst(_ value: String) -> String {
|
|
guard let first = value.first else { return value }
|
|
return String(first).uppercased() + value.dropFirst()
|
|
}
|
|
|
|
private extension String {
|
|
func dropPrefix(_ prefix: String) -> String? {
|
|
guard hasPrefix(prefix) else { return nil }
|
|
return String(dropFirst(prefix.count))
|
|
}
|
|
|
|
func dropSuffix(_ suffix: String) -> String? {
|
|
guard hasSuffix(suffix) else { return nil }
|
|
return String(dropLast(suffix.count))
|
|
}
|
|
}
|