Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ let package = Package(
.product(name: "SwiftSyntax", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
.product(name: "SwiftBasicFormat", package: "swift-syntax"),
.product(name: "SwiftDiagnostics", package: "swift-syntax"),
.product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
]
),
.testTarget(
Expand All @@ -82,6 +85,10 @@ let package = Package(
"JSONSchemaMacro",
.product(name: "SwiftSyntaxMacroExpansion", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacrosGenericTestSupport", package: "swift-syntax"),
.product(name: "SwiftParser", package: "swift-syntax"),
.product(name: "SwiftParserDiagnostics", package: "swift-syntax"),
.product(name: "SwiftBasicFormat", package: "swift-syntax"),
.product(name: "SwiftDiagnostics", package: "swift-syntax"),
]
),

Expand Down
271 changes: 271 additions & 0 deletions Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import SwiftDiagnostics
import SwiftSyntax
import SwiftSyntaxMacros

/// Handles validation and diagnostics for initializer matching in @Schemable types
struct InitializerDiagnostics {
let typeName: TokenSyntax
let members: MemberBlockItemListSyntax
let context: any MacroExpansionContext

/// Emits diagnostics when the generated schema may not match the memberwise initializer
func emitDiagnostics(for schemableMembers: [SchemableMember]) {
// Get ALL stored properties (including excluded ones) to check init mismatch
let allStoredProperties = getAllStoredProperties()
let excludedProperties = allStoredProperties.filter { prop in
!schemableMembers.contains(where: { $0.identifier.text == prop.name })
}

// Build expected parameter list from schema members
let expectedParameters: [(name: String, type: String)] = schemableMembers.map { member in
(
name: member.identifier.text,
type: member.type.description.trimmingCharacters(in: .whitespaces)
)
}

// Try to find an explicit initializer
let explicitInits = members.compactMap { $0.decl.as(InitializerDeclSyntax.self) }

if let memberWiseInit = findMatchingInit(explicitInits, expectedParameters: expectedParameters)
{
// Found a matching explicit init - validate it matches exactly
validateInitParameters(memberWiseInit, expectedParameters: expectedParameters)
} else if !explicitInits.isEmpty {
// Has explicit inits but none match - warn about this
emitNoMatchingInitWarning(
expectedParameters: expectedParameters,
availableInits: explicitInits,
excludedProperties: excludedProperties
)
} else {
// No explicit init - will use synthesized memberwise init
// Check for conditions that would break the synthesized init
validateSynthesizedInitRequirements(schemableMembers: schemableMembers)
}
}

/// Gets all stored properties including those marked with @ExcludeFromSchema
private func getAllStoredProperties() -> [(name: String, type: String)] {
members.compactMap { $0.decl.as(VariableDeclSyntax.self) }
.filter { !$0.isStatic }
.flatMap { variableDecl in
variableDecl.bindings.compactMap { binding -> (String, String)? in
guard let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier,
let type = binding.typeAnnotation?.type,
binding.isStoredProperty
else { return nil }

return (
name: identifier.text,
type: type.description.trimmingCharacters(in: .whitespaces)
)
}
}
}

/// Finds an initializer that matches the expected parameters
private func findMatchingInit(
_ inits: [InitializerDeclSyntax],
expectedParameters: [(name: String, type: String)]
) -> InitializerDeclSyntax? {
for initDecl in inits {
let params = initDecl.signature.parameterClause.parameters
if params.count == expectedParameters.count {
// Check if all expected parameter names exist (regardless of order)
let paramNames = Set(params.map { $0.secondName?.text ?? $0.firstName.text })
let expectedNames = Set(expectedParameters.map { $0.name })

if paramNames == expectedNames {
// Found an init with the right parameters (possibly wrong order)
return initDecl
}
}
}
return nil
}

/// Validates that an explicit init's parameters match the schema exactly
private func validateInitParameters(
_ initDecl: InitializerDeclSyntax,
expectedParameters: [(name: String, type: String)]
) {
let params = initDecl.signature.parameterClause.parameters
for (index, (param, expected)) in zip(params, expectedParameters).enumerated() {
let paramName = param.secondName?.text ?? param.firstName.text
let paramType = param.type.description.trimmingCharacters(in: .whitespaces)

// Check if parameter order is different
if paramName != expected.name {
let diagnostic = Diagnostic(
node: param,
message: InitializerMismatchDiagnostic.parameterOrderMismatch(
position: index + 1,
expectedName: expected.name,
actualName: paramName
)
)
context.diagnose(diagnostic)
} else {
// Only check types when names match (otherwise type comparison is meaningless)
// Check if types are obviously different (note: this is string comparison, not semantic)
if paramType != expected.type {
let diagnostic = Diagnostic(
node: param.type,
message: InitializerMismatchDiagnostic.parameterTypeMismatch(
parameterName: paramName,
expectedType: expected.type,
actualType: paramType
)
)
context.diagnose(diagnostic)
}
}
}
}

/// Emits a warning when no matching initializer is found
private func emitNoMatchingInitWarning(
expectedParameters: [(name: String, type: String)],
availableInits: [InitializerDeclSyntax],
excludedProperties: [(name: String, type: String)]
) {
let expectedSignature = expectedParameters.map { "\($0.name): \($0.type)" }
.joined(separator: ", ")

let availableSignatures = availableInits.map { initDecl -> String in
let params = initDecl.signature.parameterClause.parameters
.map { param in
let name = param.secondName?.text ?? param.firstName.text
let type = param.type.description.trimmingCharacters(in: .whitespaces)
return "\(name): \(type)"
}
.joined(separator: ", ")
return "init(\(params))"
}

let diagnostic = Diagnostic(
node: typeName,
message: InitializerMismatchDiagnostic.noMatchingInit(
typeName: typeName.text,
expectedSignature: expectedSignature,
availableInits: availableSignatures,
excludedProperties: excludedProperties.map { $0.name }
)
)
context.diagnose(diagnostic)
}

/// Validates requirements for synthesized memberwise init
private func validateSynthesizedInitRequirements(schemableMembers: [SchemableMember]) {
// Check for properties with default values - these won't be in synthesized init
// Only warn if there's a MIX of properties with and without defaults
// (if ALL have defaults, the init() with no params is intentional and fine)
let membersWithDefaults = schemableMembers.filter { $0.defaultValue != nil }
let membersWithoutDefaults = schemableMembers.filter { $0.defaultValue == nil }

// Only emit diagnostic if there are BOTH properties with defaults AND without
if !membersWithDefaults.isEmpty && !membersWithoutDefaults.isEmpty {
for member in membersWithDefaults {
let diagnostic = Diagnostic(
node: member.identifier,
message: InitializerMismatchDiagnostic.propertyHasDefault(
propertyName: member.identifier.text
)
)
context.diagnose(diagnostic)
}
}
}
}

/// Diagnostic messages for initializer mismatches
enum InitializerMismatchDiagnostic: DiagnosticMessage {
case propertyHasDefault(propertyName: String)
case parameterOrderMismatch(position: Int, expectedName: String, actualName: String)
case parameterTypeMismatch(parameterName: String, expectedType: String, actualType: String)
case noMatchingInit(
typeName: String,
expectedSignature: String,
availableInits: [String],
excludedProperties: [String]
)

var message: String {
switch self {
case .propertyHasDefault(let propertyName):
return
"Property '\(propertyName)' has a default value which will be excluded from the memberwise initializer"

case .parameterOrderMismatch(let position, let expectedName, let actualName):
return """
Initializer parameter at position \(position) is '\(actualName)' but schema expects '\(expectedName)'. \
The schema will generate properties in a different order than the initializer parameters.
"""

case .parameterTypeMismatch(let parameterName, let expectedType, let actualType):
return """
Parameter '\(parameterName)' has type '\(actualType)' but schema expects '\(expectedType)'. \
This type mismatch will cause the generated schema to fail.
"""

case .noMatchingInit(
let typeName,
let expectedSignature,
let availableInits,
let excludedProperties
):
var msg = """
Type '\(typeName)' has explicit initializers, but none match the expected schema signature.

Expected: init(\(expectedSignature))
"""
if !availableInits.isEmpty {
msg += "\n\nAvailable initializers:"
for initSig in availableInits {
msg += "\n - \(initSig)"
}
}
if !excludedProperties.isEmpty {
let excludedList = excludedProperties.map { "'\($0)'" }.joined(separator: ", ")
msg += """


Note: The following properties are excluded from the schema using @ExcludeFromSchema: \(excludedList)
These will still be present in the memberwise initializer but not in the schema.
"""
}
msg += """


The generated schema expects JSONSchema(\(typeName).init) to use an initializer that \
matches all schema properties. Consider adding a matching initializer or adjusting the schema properties.
"""
return msg
}
}

var diagnosticID: MessageID {
switch self {
case .propertyHasDefault:
return MessageID(domain: "JSONSchemaMacro", id: "propertyHasDefault")
case .parameterOrderMismatch:
return MessageID(domain: "JSONSchemaMacro", id: "parameterOrderMismatch")
case .parameterTypeMismatch:
return MessageID(domain: "JSONSchemaMacro", id: "parameterTypeMismatch")
case .noMatchingInit:
return MessageID(domain: "JSONSchemaMacro", id: "noMatchingInit")
}
}

var severity: DiagnosticSeverity {
switch self {
case .propertyHasDefault:
return .warning
case .parameterOrderMismatch, .parameterTypeMismatch:
return .error
case .noMatchingInit:
return .error
}
}
}
37 changes: 34 additions & 3 deletions Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import SwiftDiagnostics
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

struct EnumSchemaGenerator {
let declModifier: DeclModifierSyntax?
Expand Down Expand Up @@ -101,8 +103,14 @@ struct SchemaGenerator {
let members: MemberBlockItemListSyntax
let attributes: AttributeListSyntax
let keyStrategy: ExprSyntax?
let context: (any MacroExpansionContext)?

init(fromClass classDecl: ClassDeclSyntax, keyStrategy: ExprSyntax?, accessLevel: String? = nil) {
init(
fromClass classDecl: ClassDeclSyntax,
keyStrategy: ExprSyntax?,
accessLevel: String? = nil,
context: (any MacroExpansionContext)? = nil
) {
// Use provided access level if available, otherwise use the declaration's modifier
if let accessLevel {
// Create modifier with trailing space for proper formatting
Expand All @@ -117,12 +125,14 @@ struct SchemaGenerator {
members = classDecl.memberBlock.members
attributes = classDecl.attributes
self.keyStrategy = keyStrategy
self.context = context
}

init(
fromStruct structDecl: StructDeclSyntax,
keyStrategy: ExprSyntax?,
accessLevel: String? = nil
accessLevel: String? = nil,
context: (any MacroExpansionContext)? = nil
) {
// Use provided access level if available, otherwise use the declaration's modifier
if let accessLevel {
Expand All @@ -138,14 +148,35 @@ struct SchemaGenerator {
members = structDecl.memberBlock.members
attributes = structDecl.attributes
self.keyStrategy = keyStrategy
self.context = context
}

func makeSchema() -> DeclSyntax {
let schemableMembers = members.schemableMembers()
let codingKeys = members.extractCodingKeys()

// Emit diagnostics for potential memberwise init mismatches
if let context = context {
let diagnostics = InitializerDiagnostics(
typeName: name,
members: members,
context: context
)
diagnostics.emitDiagnostics(for: schemableMembers)

// Validate schema options for each member
for member in schemableMembers {
member.validateOptions(context: context)
}
}

let statements = schemableMembers.compactMap {
$0.generateSchema(keyStrategy: keyStrategy, typeName: name.text, codingKeys: codingKeys)
$0.generateSchema(
keyStrategy: keyStrategy,
typeName: name.text,
codingKeys: codingKeys,
context: context
)
}

var codeBlockItem: CodeBlockItemSyntax =
Expand Down
Loading