mirror of
https://github.com/apple/sourcekit-lsp.git
synced 2025-12-17 12:03:01 +01:00
318 lines
11 KiB
Swift
318 lines
11 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
//
|
|
// This source file is part of the Swift.org open source project
|
|
//
|
|
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
|
|
// Licensed under Apache License v2.0 with Runtime Library Exception
|
|
//
|
|
// See https://swift.org/LICENSE.txt for license information
|
|
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
|
|
//
|
|
//===----------------------------------------------------------------------===//
|
|
|
|
import SwiftSyntax
|
|
|
|
/// Intermediate type schema representation for option types derived from Swift
|
|
/// syntax nodes
|
|
struct OptionTypeSchama {
|
|
struct Property {
|
|
var name: String
|
|
var type: OptionTypeSchama
|
|
var description: String?
|
|
var defaultValue: String?
|
|
}
|
|
|
|
struct Struct {
|
|
var name: String
|
|
/// Properties of the object, preserving the order of declaration
|
|
var properties: [Property]
|
|
}
|
|
|
|
struct Case {
|
|
var name: String
|
|
var description: String?
|
|
var associatedProperties: [Property]?
|
|
}
|
|
|
|
struct Enum {
|
|
var name: String
|
|
var cases: [Case]
|
|
var discriminatorFieldName: String?
|
|
}
|
|
|
|
enum Kind {
|
|
case boolean
|
|
case integer
|
|
case number
|
|
case string
|
|
indirect case array(value: OptionTypeSchama)
|
|
indirect case dictionary(value: OptionTypeSchama)
|
|
case `struct`(Struct)
|
|
case `enum`(Enum)
|
|
}
|
|
|
|
var kind: Kind
|
|
var isOptional: Bool
|
|
|
|
init(kind: Kind, isOptional: Bool = false) {
|
|
self.kind = kind
|
|
self.isOptional = isOptional
|
|
}
|
|
|
|
/// Accesses the property schema by name
|
|
subscript(_ key: String) -> OptionTypeSchama? {
|
|
get {
|
|
guard case .struct(let structInfo) = kind else {
|
|
return nil
|
|
}
|
|
return structInfo.properties.first { $0.name == key }?.type
|
|
}
|
|
set {
|
|
guard case .struct(var structInfo) = kind else {
|
|
fatalError("Cannot set property on non-object type")
|
|
}
|
|
guard let index = structInfo.properties.firstIndex(where: { $0.name == key }) else {
|
|
fatalError("Property not found: \(key)")
|
|
}
|
|
guard let newValue = newValue else {
|
|
fatalError("Cannot set property to nil")
|
|
}
|
|
structInfo.properties[index].type = newValue
|
|
kind = .struct(structInfo)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Context for resolving option schema from Swift syntax nodes
|
|
struct OptionSchemaContext {
|
|
private let typeNameResolver: TypeDeclResolver
|
|
|
|
init(typeNameResolver: TypeDeclResolver) {
|
|
self.typeNameResolver = typeNameResolver
|
|
}
|
|
|
|
/// Builds a schema from a type declaration
|
|
func buildSchema(from typeDecl: TypeDeclResolver.TypeDecl) throws -> OptionTypeSchama {
|
|
switch DeclSyntax(typeDecl).as(DeclSyntaxEnum.self) {
|
|
case .structDecl(let decl):
|
|
let structInfo = try buildStructProperties(decl)
|
|
return OptionTypeSchama(kind: .struct(structInfo))
|
|
case .enumDecl(let decl):
|
|
let enumInfo = try buildEnumCases(decl)
|
|
return OptionTypeSchama(kind: .enum(enumInfo))
|
|
default:
|
|
throw ConfigSchemaGenError("Unsupported type declaration: \(typeDecl)")
|
|
}
|
|
}
|
|
|
|
/// Resolves the type of a given type usage
|
|
private func resolveType(_ type: TypeSyntax) throws -> OptionTypeSchama {
|
|
switch type.as(TypeSyntaxEnum.self) {
|
|
case .optionalType(let type):
|
|
var wrapped = try resolveType(type.wrappedType)
|
|
guard !wrapped.isOptional else {
|
|
throw ConfigSchemaGenError("Nested optional type is not supported")
|
|
}
|
|
wrapped.isOptional = true
|
|
return wrapped
|
|
case .arrayType(let type):
|
|
let value = try resolveType(type.element)
|
|
return OptionTypeSchama(kind: .array(value: value))
|
|
case .dictionaryType(let type):
|
|
guard type.key.trimmedDescription == "String" else {
|
|
throw ConfigSchemaGenError("Dictionary key type must be String: \(type.key)")
|
|
}
|
|
let value = try resolveType(type.value)
|
|
return OptionTypeSchama(kind: .dictionary(value: value))
|
|
case .identifierType(let type):
|
|
let primitiveTypes: [String: OptionTypeSchama.Kind] = [
|
|
"String": .string,
|
|
"Int": .integer,
|
|
"Double": .number,
|
|
"Bool": .boolean,
|
|
]
|
|
if let primitiveType = primitiveTypes[type.trimmedDescription] {
|
|
return OptionTypeSchama(kind: primitiveType)
|
|
} else if type.name.trimmedDescription == "Set" {
|
|
guard let elementType = type.genericArgumentClause?.arguments.first?.argument else {
|
|
throw ConfigSchemaGenError("Set type must have one generic argument: \(type)")
|
|
}
|
|
return OptionTypeSchama(kind: .array(value: try resolveType(elementType)))
|
|
} else {
|
|
let type = try typeNameResolver.lookupType(for: type)
|
|
return try buildSchema(from: type)
|
|
}
|
|
default:
|
|
throw ConfigSchemaGenError("Unsupported type syntax: \(type)")
|
|
}
|
|
}
|
|
|
|
private func buildEnumCases(_ node: EnumDeclSyntax) throws -> OptionTypeSchama.Enum {
|
|
let discriminatorFieldName = Self.extractDiscriminatorFieldName(node.leadingTrivia)
|
|
|
|
let cases = try node.memberBlock.members.flatMap { member -> [OptionTypeSchama.Case] in
|
|
guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else {
|
|
return []
|
|
}
|
|
return try caseDecl.elements.compactMap {
|
|
let name: String
|
|
if let rawValue = $0.rawValue?.value {
|
|
if let stringLiteral = rawValue.as(StringLiteralExprSyntax.self),
|
|
let literalValue = stringLiteral.representedLiteralValue
|
|
{
|
|
name = literalValue
|
|
} else {
|
|
throw ConfigSchemaGenError(
|
|
"Only string literals without interpolation are supported as enum case raw values: \(caseDecl)"
|
|
)
|
|
}
|
|
} else {
|
|
name = $0.name.text
|
|
}
|
|
let description = Self.extractDocComment(caseDecl.leadingTrivia)
|
|
if description?.contains("- Note: Internal option") ?? false {
|
|
return nil
|
|
}
|
|
|
|
var associatedProperties: [OptionTypeSchama.Property]? = nil
|
|
if let parameterClause = $0.parameterClause {
|
|
let caseDescription = description
|
|
associatedProperties = try parameterClause.parameters.map { param in
|
|
let propertyName: String
|
|
if let firstName = param.firstName, firstName.tokenKind != .wildcard {
|
|
propertyName = firstName.text
|
|
} else if let secondName = param.secondName {
|
|
propertyName = secondName.text
|
|
} else {
|
|
propertyName = name
|
|
}
|
|
|
|
let propertyType = try resolveType(param.type)
|
|
let propertyDescription =
|
|
Self.extractParameterDescription(
|
|
from: caseDescription,
|
|
parameterName: propertyName
|
|
) ?? Self.extractDocComment(param.leadingTrivia)
|
|
|
|
return OptionTypeSchama.Property(
|
|
name: propertyName,
|
|
type: propertyType,
|
|
description: propertyDescription,
|
|
defaultValue: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
return OptionTypeSchama.Case(
|
|
name: name,
|
|
description: description,
|
|
associatedProperties: associatedProperties
|
|
)
|
|
}
|
|
}
|
|
let typeName = node.name.text
|
|
return .init(name: typeName, cases: cases, discriminatorFieldName: discriminatorFieldName)
|
|
}
|
|
|
|
private func buildStructProperties(_ node: StructDeclSyntax) throws -> OptionTypeSchama.Struct {
|
|
var properties: [OptionTypeSchama.Property] = []
|
|
for member in node.memberBlock.members {
|
|
// Skip computed properties
|
|
guard let variable = member.decl.as(VariableDeclSyntax.self),
|
|
let binding = variable.bindings.first,
|
|
let type = binding.typeAnnotation,
|
|
binding.accessorBlock == nil
|
|
else { continue }
|
|
|
|
let name = binding.pattern.trimmed.description
|
|
let defaultValue = binding.initializer?.value.description
|
|
let description = Self.extractDocComment(variable.leadingTrivia)
|
|
if description?.contains("- Note: Internal option") ?? false {
|
|
continue
|
|
}
|
|
let typeInfo = try resolveType(type.type)
|
|
properties.append(
|
|
.init(name: name, type: typeInfo, description: description, defaultValue: defaultValue)
|
|
)
|
|
}
|
|
let typeName = node.name.text
|
|
return .init(name: typeName, properties: properties)
|
|
}
|
|
|
|
private static func extractDocComment(_ trivia: Trivia) -> String? {
|
|
var docLines = trivia.flatMap { piece in
|
|
switch piece {
|
|
case .docBlockComment(let text):
|
|
// Remove `/**` and `*/`
|
|
assert(text.hasPrefix("/**") && text.hasSuffix("*/"), "Unexpected doc block comment format: \(text)")
|
|
return text.dropFirst(3).dropLast(2).split { $0.isNewline }
|
|
case .docLineComment(let text):
|
|
// Remove `///` and leading space
|
|
assert(text.hasPrefix("///"), "Unexpected doc line comment format: \(text)")
|
|
let text = text.dropFirst(3)
|
|
return [text]
|
|
default:
|
|
return []
|
|
}
|
|
}
|
|
guard !docLines.isEmpty else {
|
|
return nil
|
|
}
|
|
// Trim leading spaces for each line and skip empty lines
|
|
docLines = docLines.compactMap {
|
|
guard !$0.isEmpty else { return nil }
|
|
var trimmed = $0
|
|
while trimmed.first?.isWhitespace == true {
|
|
trimmed = trimmed.dropFirst()
|
|
}
|
|
return trimmed
|
|
}
|
|
return docLines.joined(separator: " ")
|
|
}
|
|
|
|
private static func extractDiscriminatorFieldName(_ trivia: Trivia) -> String? {
|
|
let docLines = trivia.flatMap { piece -> [Substring] in
|
|
switch piece {
|
|
case .docBlockComment(let text):
|
|
assert(text.hasPrefix("/**") && text.hasSuffix("*/"), "Unexpected doc block comment format: \(text)")
|
|
return text.dropFirst(3).dropLast(2).split { $0.isNewline }
|
|
case .docLineComment(let text):
|
|
assert(text.hasPrefix("///"), "Unexpected doc line comment format: \(text)")
|
|
let text = text.dropFirst(3)
|
|
return [text]
|
|
default:
|
|
return []
|
|
}
|
|
}
|
|
|
|
for line in docLines {
|
|
let trimmed = line.drop(while: \.isWhitespace)
|
|
if trimmed.hasPrefix("- discriminator:") {
|
|
let fieldName = trimmed.dropFirst("- discriminator:".count).trimmingCharacters(in: .whitespaces)
|
|
return fieldName.isEmpty ? nil : fieldName
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func extractParameterDescription(from docComment: String?, parameterName: String) -> String? {
|
|
guard let docComment = docComment else {
|
|
return nil
|
|
}
|
|
|
|
let pattern = "`\(parameterName)`:"
|
|
guard let range = docComment.range(of: pattern) else {
|
|
return nil
|
|
}
|
|
|
|
let afterPattern = docComment[range.upperBound...]
|
|
let lines = afterPattern.split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false)
|
|
guard let firstLine = lines.first else {
|
|
return nil
|
|
}
|
|
|
|
let description = firstLine.trimmingCharacters(in: .whitespaces)
|
|
return description.isEmpty ? nil : description
|
|
}
|
|
}
|