|
| 1 | +//===----------------------------------------------------------------------===// |
| 2 | +// |
| 3 | +// This source file is part of the Swift.org open source project |
| 4 | +// |
| 5 | +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors |
| 6 | +// Licensed under Apache License v2.0 with Runtime Library Exception |
| 7 | +// |
| 8 | +// See https://swift.org/LICENSE.txt for license information |
| 9 | +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors |
| 10 | +// |
| 11 | +//===----------------------------------------------------------------------===// |
| 12 | + |
| 13 | +import Foundation |
| 14 | +import LanguageServerProtocol |
| 15 | +import SwiftBasicFormat |
| 16 | +import SwiftRefactor |
| 17 | +import SwiftSyntax |
| 18 | + |
| 19 | +/// Convert JSON literals into corresponding Swift structs that conform to the |
| 20 | +/// `Codable` protocol. |
| 21 | +/// |
| 22 | +/// ## Before |
| 23 | +/// |
| 24 | +/// ```javascript |
| 25 | +/// { |
| 26 | +/// "name": "Produce", |
| 27 | +/// "shelves": [ |
| 28 | +/// { |
| 29 | +/// "name": "Discount Produce", |
| 30 | +/// "product": { |
| 31 | +/// "name": "Banana", |
| 32 | +/// "points": 200, |
| 33 | +/// "description": "A banana that's perfectly ripe." |
| 34 | +/// } |
| 35 | +/// } |
| 36 | +/// ] |
| 37 | +/// } |
| 38 | +/// ``` |
| 39 | +/// |
| 40 | +/// ## After |
| 41 | +/// |
| 42 | +/// ```swift |
| 43 | +/// struct JSONValue: Codable { |
| 44 | +/// var name: String |
| 45 | +/// var shelves: [Shelves] |
| 46 | +/// |
| 47 | +/// struct Shelves: Codable { |
| 48 | +/// var name: String |
| 49 | +/// var product: Product |
| 50 | +/// |
| 51 | +/// struct Product: Codable { |
| 52 | +/// var description: String |
| 53 | +/// var name: String |
| 54 | +/// var points: Double |
| 55 | +/// } |
| 56 | +/// } |
| 57 | +/// } |
| 58 | +/// ``` |
| 59 | +@_spi(Testing) |
| 60 | +public struct ConvertJSONToCodableStruct: EditRefactoringProvider { |
| 61 | + @_spi(Testing) |
| 62 | + public static func textRefactor( |
| 63 | + syntax: Syntax, |
| 64 | + in context: Void |
| 65 | + ) -> [SourceEdit] { |
| 66 | + // Dig out a syntax node that looks like it might be JSON or have JSON |
| 67 | + // in it. |
| 68 | + guard let preflight = preflightRefactoring(syntax) else { |
| 69 | + return [] |
| 70 | + } |
| 71 | + |
| 72 | + // Dig out the text that we think might be JSON. |
| 73 | + let text: String |
| 74 | + switch preflight { |
| 75 | + case let .closure(closure): |
| 76 | + /// The outer structure of the JSON { ... } looks like a closure in the |
| 77 | + /// syntax tree, albeit one with lots of ill-formed syntax in the body. |
| 78 | + /// We're only going to look at the text of the closure to see if we |
| 79 | + /// have JSON in there. |
| 80 | + text = closure.trimmedDescription |
| 81 | + case .stringLiteral(_, let literalText): |
| 82 | + /// A string literal that could contain JSON within it. |
| 83 | + text = literalText |
| 84 | + } |
| 85 | + |
| 86 | + // Try to process this as JSON. |
| 87 | + guard |
| 88 | + let object = try? JSONSerialization.jsonObject(with: text.data(using: .utf8)!), |
| 89 | + let dictionary = object as? [String: Any] |
| 90 | + else { |
| 91 | + return [] |
| 92 | + } |
| 93 | + |
| 94 | + // Create the top-level object. |
| 95 | + let topLevelObject = JSONObject(dictionary: dictionary) |
| 96 | + |
| 97 | + // Render the top-level object as a struct. |
| 98 | + let indentation = BasicFormat.inferIndentation(of: syntax) |
| 99 | + let format = BasicFormat(indentationWidth: indentation) |
| 100 | + let decls = topLevelObject.asDeclSyntax(name: "JSONValue") |
| 101 | + .formatted(using: format) |
| 102 | + |
| 103 | + // Render the change into a set of source edits. |
| 104 | + switch preflight { |
| 105 | + case .closure(let closure): |
| 106 | + // Closures are replaced entirely, since they were invalid code to |
| 107 | + // start with. |
| 108 | + return [ |
| 109 | + SourceEdit(range: closure.trimmedRange, replacement: decls.description) |
| 110 | + ] |
| 111 | + case .stringLiteral(let literal, _): |
| 112 | + /// Leave the string literal in place (it might be there for testing |
| 113 | + /// purposes), and put the newly-created structs afterward. |
| 114 | + return [ |
| 115 | + SourceEdit( |
| 116 | + range: literal.endPosition..<literal.endPosition, |
| 117 | + replacement: "\n" + decls.description |
| 118 | + ) |
| 119 | + ] |
| 120 | + } |
| 121 | + } |
| 122 | +} |
| 123 | + |
| 124 | +extension ConvertJSONToCodableStruct { |
| 125 | + /// The result of preflighting a syntax node to try to find potential JSON |
| 126 | + /// in it. |
| 127 | + enum Preflight { |
| 128 | + /// A closure, which is what a JSON dictionary looks like when pasted |
| 129 | + /// into Swift. |
| 130 | + case closure(ClosureExprSyntax) |
| 131 | + |
| 132 | + /// A string literal that may contain JSON. |
| 133 | + case stringLiteral(StringLiteralExprSyntax, String) |
| 134 | + } |
| 135 | + |
| 136 | + /// Look for either a closure or a string literal that might have JSON in it. |
| 137 | + static func preflightRefactoring(_ syntax: Syntax) -> Preflight? { |
| 138 | + // Preflight a closure. |
| 139 | + // |
| 140 | + // A blob of JSON dropped into a Swift source file will look like a |
| 141 | + // closure due to the curly braces. The internals might be a syntactic |
| 142 | + // disaster, but we don't actually care. |
| 143 | + if let closure = syntax.as(ClosureExprSyntax.self) { |
| 144 | + return .closure(closure) |
| 145 | + } |
| 146 | + |
| 147 | + // We found a string literal; its contents might be JSON. |
| 148 | + if let stringLit = syntax.as(StringLiteralExprSyntax.self) { |
| 149 | + // Look for an enclosing context and prefer that, because we might have |
| 150 | + // a string literal that's inside a closure where the closure itself |
| 151 | + // is the JSON. |
| 152 | + if let parent = syntax.parent, |
| 153 | + let enclosingPreflight = preflightRefactoring(parent) |
| 154 | + { |
| 155 | + return enclosingPreflight |
| 156 | + } |
| 157 | + |
| 158 | + guard let text = stringLit.representedLiteralValue else { |
| 159 | + return nil |
| 160 | + } |
| 161 | + |
| 162 | + return .stringLiteral(stringLit, text) |
| 163 | + } |
| 164 | + |
| 165 | + // Look further up the syntax tree. |
| 166 | + if let parent = syntax.parent { |
| 167 | + return preflightRefactoring(parent) |
| 168 | + } |
| 169 | + |
| 170 | + return nil |
| 171 | + } |
| 172 | +} |
| 173 | + |
| 174 | +extension ConvertJSONToCodableStruct: SyntaxRefactoringCodeActionProvider { |
| 175 | + static var title = "Create Codable structs from JSON" |
| 176 | +} |
| 177 | + |
| 178 | +/// A JSON object, which is has a set of fields, each of which has the given |
| 179 | +/// type. |
| 180 | +fileprivate struct JSONObject { |
| 181 | + /// The fields of the JSON object. |
| 182 | + var fields: [String: JSONType] = [:] |
| 183 | + |
| 184 | + /// Form a JSON object from its fields. |
| 185 | + private init(fields: [String: JSONType]) { |
| 186 | + self.fields = fields |
| 187 | + } |
| 188 | + |
| 189 | + /// Form a JSON object given a dictionary. |
| 190 | + init(dictionary: [String: Any]) { |
| 191 | + fields = dictionary.mapValues { JSONType(value: $0) } |
| 192 | + } |
| 193 | + |
| 194 | + /// Merge the fields of this JSON object with another JSON object to produce |
| 195 | + /// a JSON object |
| 196 | + func merging(with other: JSONObject) -> JSONObject { |
| 197 | + // Collect the set of all keys from both JSON objects. |
| 198 | + var allKeys: Set<String> = [] |
| 199 | + allKeys.formUnion(fields.keys) |
| 200 | + allKeys.formUnion(other.fields.keys) |
| 201 | + |
| 202 | + // Form a new JSON object containing the union of the fields |
| 203 | + let newFields = allKeys.map { key in |
| 204 | + let myValue = fields[key] ?? .null |
| 205 | + let otherValue = other.fields[key] ?? .null |
| 206 | + return (key, myValue.merging(with: otherValue)) |
| 207 | + } |
| 208 | + return JSONObject(fields: [String: JSONType](uniqueKeysWithValues: newFields)) |
| 209 | + } |
| 210 | + |
| 211 | + /// Render this JSON object into a struct. |
| 212 | + func asDeclSyntax(name: String) -> DeclSyntax { |
| 213 | + /// The list of fields in this object, sorted alphabetically. |
| 214 | + let sortedFields = fields.sorted(by: { $0.key < $1.key }) |
| 215 | + |
| 216 | + // Collect the nested types |
| 217 | + let nestedTypes: [(String, JSONObject)] = sortedFields.compactMap { (name, type) in |
| 218 | + guard let object = type.innerObject else { |
| 219 | + return nil |
| 220 | + } |
| 221 | + |
| 222 | + return (name.capitalized, object) |
| 223 | + } |
| 224 | + |
| 225 | + let members = MemberBlockItemListSyntax { |
| 226 | + // Print the fields of this type. |
| 227 | + for (fieldName, fieldType) in sortedFields { |
| 228 | + MemberBlockItemSyntax( |
| 229 | + leadingTrivia: .newline, |
| 230 | + decl: "var \(raw: fieldName): \(fieldType.asTypeSyntax(name: fieldName))" as DeclSyntax |
| 231 | + ) |
| 232 | + } |
| 233 | + |
| 234 | + // Print any nested types. |
| 235 | + for (typeName, object) in nestedTypes { |
| 236 | + MemberBlockItemSyntax( |
| 237 | + leadingTrivia: (typeName == nestedTypes.first?.0) ? .newlines(2) : .newline, |
| 238 | + decl: object.asDeclSyntax(name: typeName) |
| 239 | + ) |
| 240 | + } |
| 241 | + } |
| 242 | + |
| 243 | + return """ |
| 244 | + struct \(raw: name): Codable { |
| 245 | + \(members.trimmed) |
| 246 | + } |
| 247 | + """ |
| 248 | + } |
| 249 | +} |
| 250 | + |
| 251 | +/// Describes the type of JSON data. |
| 252 | +fileprivate enum JSONType { |
| 253 | + /// String data |
| 254 | + case string |
| 255 | + |
| 256 | + /// Numeric data |
| 257 | + case number |
| 258 | + |
| 259 | + /// Boolean data |
| 260 | + case boolean |
| 261 | + |
| 262 | + /// A "null", which implies optionality but without any underlying type |
| 263 | + /// information. |
| 264 | + case null |
| 265 | + |
| 266 | + /// An array. |
| 267 | + indirect case array(JSONType) |
| 268 | + |
| 269 | + /// An object. |
| 270 | + indirect case object(JSONObject) |
| 271 | + |
| 272 | + /// A value that is optional, for example because it is missing or null in |
| 273 | + /// other cases. |
| 274 | + indirect case optional(JSONType) |
| 275 | + |
| 276 | + /// Determine the type of a JSON value. |
| 277 | + init(value: Any) { |
| 278 | + switch value { |
| 279 | + case let string as String: |
| 280 | + if string == "true" || string == "false" { |
| 281 | + self = .boolean |
| 282 | + } else { |
| 283 | + self = .string |
| 284 | + } |
| 285 | + case is NSNumber: |
| 286 | + self = .number |
| 287 | + case is NSArray: |
| 288 | + let array = value as! [Any] |
| 289 | + |
| 290 | + // Use null as a fallback for an empty array. |
| 291 | + guard let firstValue = array.first else { |
| 292 | + self = .array(.null) |
| 293 | + return |
| 294 | + } |
| 295 | + |
| 296 | + // Merge the array elements. |
| 297 | + let elementType: JSONType = array[1...].reduce( |
| 298 | + JSONType(value: firstValue) |
| 299 | + ) { (result, value) in |
| 300 | + result.merging(with: JSONType(value: value)) |
| 301 | + } |
| 302 | + self = .array(elementType) |
| 303 | + |
| 304 | + case is NSNull: |
| 305 | + self = .null |
| 306 | + case is NSDictionary: |
| 307 | + self = .object(JSONObject(dictionary: value as! [String: Any])) |
| 308 | + default: |
| 309 | + self = .string |
| 310 | + } |
| 311 | + } |
| 312 | + |
| 313 | + /// Merge this JSON type with another JSON type, producing a new JSON type |
| 314 | + /// that abstracts over the two. |
| 315 | + func merging(with other: JSONType) -> JSONType { |
| 316 | + switch (self, other) { |
| 317 | + // Exact matches are easy. |
| 318 | + case (.string, .string): return .string |
| 319 | + case (.number, .number): return .number |
| 320 | + case (.boolean, .boolean): return .boolean |
| 321 | + case (.null, .null): return .null |
| 322 | + |
| 323 | + case (.array(let inner), .array(.null)), (.array(.null), .array(let inner)): |
| 324 | + // Merging an array with an array of null leaves the array. |
| 325 | + return .array(inner) |
| 326 | + |
| 327 | + case (.array(let inner), .null), (.null, .array(let inner)): |
| 328 | + // Merging an array with a null just leaves an array. |
| 329 | + return .array(inner) |
| 330 | + |
| 331 | + case (.array(let left), .array(let right)): |
| 332 | + // Merging two arrays merges the element types |
| 333 | + return .array(left.merging(with: right)) |
| 334 | + |
| 335 | + case (.object(let left), .object(let right)): |
| 336 | + // Merging two arrays merges the element types |
| 337 | + return .object(left.merging(with: right)) |
| 338 | + |
| 339 | + // Merging a string with a Boolean means we misinterpreted "true" or |
| 340 | + // "false" as Boolean when it was meant as a string. |
| 341 | + case (.string, .boolean), (.boolean, .string): return .string |
| 342 | + |
| 343 | + // Merging 'null' with an optional returns the optional. |
| 344 | + case (.optional(let inner), .null), (.null, .optional(let inner)): |
| 345 | + return .optional(inner) |
| 346 | + |
| 347 | + // Merging 'null' with anything else makes it an optional. |
| 348 | + case (let inner, .null), (.null, let inner): |
| 349 | + return .optional(inner) |
| 350 | + |
| 351 | + // Merging two optionals merges the underlying types and makes the |
| 352 | + // result optional. |
| 353 | + case (.optional(let left), .optional(let right)): |
| 354 | + return .optional(left.merging(with: right)) |
| 355 | + |
| 356 | + // Merging an optional with anything else merges the underlying bits and |
| 357 | + // makes them optional. |
| 358 | + case (let outer, .optional(let inner)), (.optional(let inner), let outer): |
| 359 | + return .optional(inner.merging(with: outer)) |
| 360 | + |
| 361 | + // Fall back to the null case when we don't know. |
| 362 | + default: |
| 363 | + return .null |
| 364 | + } |
| 365 | + } |
| 366 | + |
| 367 | + /// Dig out the JSON inner object referenced by this type. |
| 368 | + var innerObject: JSONObject? { |
| 369 | + switch self { |
| 370 | + case .string, .null, .number, .boolean: nil |
| 371 | + case .optional(let inner): inner.innerObject |
| 372 | + case .array(let inner): inner.innerObject |
| 373 | + case .object(let object): object |
| 374 | + } |
| 375 | + } |
| 376 | + |
| 377 | + /// Render this JSON type into type syntax. |
| 378 | + func asTypeSyntax(name: String) -> TypeSyntax { |
| 379 | + switch self { |
| 380 | + case .string: "String" |
| 381 | + case .number: "Double" |
| 382 | + case .boolean: "Bool" |
| 383 | + case .null: "Void" |
| 384 | + case .optional(let inner): "\(inner.asTypeSyntax(name: name))?" |
| 385 | + case .array(let inner): "[\(inner.asTypeSyntax(name: name))]" |
| 386 | + case .object(_): "\(raw: name.capitalized)" |
| 387 | + } |
| 388 | + } |
| 389 | +} |
0 commit comments