diff --git a/Package.swift b/Package.swift index a93c4166..b5509761 100644 --- a/Package.swift +++ b/Package.swift @@ -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( @@ -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"), ] ), diff --git a/Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift b/Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift new file mode 100644 index 00000000..47c7c9a6 --- /dev/null +++ b/Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift @@ -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 + } + } +} diff --git a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift index 6f9693b4..d1bae828 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift @@ -1,5 +1,7 @@ +import SwiftDiagnostics import SwiftSyntax import SwiftSyntaxBuilder +import SwiftSyntaxMacros struct EnumSchemaGenerator { let declModifier: DeclModifierSyntax? @@ -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 @@ -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 { @@ -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 = diff --git a/Sources/JSONSchemaMacro/Schemable/SchemaOptionsDiagnostics.swift b/Sources/JSONSchemaMacro/Schemable/SchemaOptionsDiagnostics.swift new file mode 100644 index 00000000..bbb967a6 --- /dev/null +++ b/Sources/JSONSchemaMacro/Schemable/SchemaOptionsDiagnostics.swift @@ -0,0 +1,497 @@ +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros + +/// Handles validation and diagnostics for @SchemaOptions and type-specific option macros +struct SchemaOptionsDiagnostics { + let propertyName: TokenSyntax + let propertyType: TypeSyntax + let context: any MacroExpansionContext + + /// Validates SchemaOptions and emits diagnostics for invalid configurations + func validateSchemaOptions(_ options: LabeledExprListSyntax) { + // Check for conflicting readOnly/writeOnly + validateReadWriteConflict(options) + + // Check for duplicate options + validateNoDuplicates(options, macroName: "SchemaOptions") + } + + /// Validates type-specific options (StringOptions, NumberOptions, etc.) + func validateTypeSpecificOptions(_ options: LabeledExprListSyntax, macroName: String) { + // 1. Check that the option macro matches the property type + validateTypeCompatibility(macroName: macroName) + + // 2. Check for logical conflicts in constraints + validateConstraintLogic(options, macroName: macroName) + + // 3. Check for invalid constraint values + validateConstraintValues(options, macroName: macroName) + + // 4. Check for duplicate options + validateNoDuplicates(options, macroName: macroName) + } + + // MARK: - Type Compatibility + + private func validateTypeCompatibility(macroName: String) { + let typeInfo = propertyType.typeInformation() + + switch macroName { + case "StringOptions": + guard case .primitive(.string, _) = typeInfo else { + emitTypeMismatch(macroName: macroName, expectedType: "String", actualType: typeInfo) + return + } + + case "NumberOptions": + switch typeInfo { + case .primitive(.int, _), .primitive(.double, _): + break // Valid numeric types + default: + emitTypeMismatch( + macroName: macroName, + expectedType: "numeric (Int, Double, etc.)", + actualType: typeInfo + ) + } + + case "ArrayOptions": + // Check if type is an array + let typeString = propertyType.description.trimmingCharacters(in: .whitespaces) + if !typeString.hasPrefix("[") && !typeString.contains("Array<") { + let diagnostic = Diagnostic( + node: propertyName, + message: SchemaOptionsMismatchDiagnostic.typeMismatch( + macroName: macroName, + propertyName: propertyName.text, + expectedType: "Array", + actualType: typeString + ) + ) + context.diagnose(diagnostic) + } + + case "ObjectOptions": + // ObjectOptions can be used on Dictionary or custom types (structs/classes) + // We'll be permissive here since many types could be objects + break + + default: + break + } + } + + private func emitTypeMismatch( + macroName: String, + expectedType: String, + actualType: TypeSyntax.TypeInformation + ) { + let actualTypeString: String + switch actualType { + case .primitive(let primitive, _): + actualTypeString = primitive.rawValue + case .schemable(let name, _): + actualTypeString = name + case .notSupported: + actualTypeString = propertyType.description.trimmingCharacters(in: .whitespaces) + } + + let diagnostic = Diagnostic( + node: propertyName, + message: SchemaOptionsMismatchDiagnostic.typeMismatch( + macroName: macroName, + propertyName: propertyName.text, + expectedType: expectedType, + actualType: actualTypeString + ) + ) + context.diagnose(diagnostic) + } + + // MARK: - Constraint Logic Validation + + private func validateConstraintLogic(_ options: LabeledExprListSyntax, macroName: String) { + let constraints = extractConstraints(from: options) + + switch macroName { + case "StringOptions": + validateMinMaxLogic( + constraints: constraints, + minKey: "minLength", + maxKey: "maxLength", + constraintType: "string length" + ) + + case "NumberOptions": + validateMinMaxLogic( + constraints: constraints, + minKey: "minimum", + maxKey: "maximum", + constraintType: "value" + ) + validateMinMaxLogic( + constraints: constraints, + minKey: "exclusiveMinimum", + maxKey: "exclusiveMaximum", + constraintType: "value" + ) + + // Check for conflicting minimum types + if constraints["minimum"] != nil && constraints["exclusiveMinimum"] != nil { + let diagnostic = Diagnostic( + node: propertyName, + message: SchemaOptionsMismatchDiagnostic.conflictingConstraints( + propertyName: propertyName.text, + constraint1: "minimum", + constraint2: "exclusiveMinimum", + suggestion: "Use only one of minimum or exclusiveMinimum" + ) + ) + context.diagnose(diagnostic) + } + + // Check for conflicting maximum types + if constraints["maximum"] != nil && constraints["exclusiveMaximum"] != nil { + let diagnostic = Diagnostic( + node: propertyName, + message: SchemaOptionsMismatchDiagnostic.conflictingConstraints( + propertyName: propertyName.text, + constraint1: "maximum", + constraint2: "exclusiveMaximum", + suggestion: "Use only one of maximum or exclusiveMaximum" + ) + ) + context.diagnose(diagnostic) + } + + case "ArrayOptions": + validateMinMaxLogic( + constraints: constraints, + minKey: "minItems", + maxKey: "maxItems", + constraintType: "array size" + ) + validateMinMaxLogic( + constraints: constraints, + minKey: "minContains", + maxKey: "maxContains", + constraintType: "contains count" + ) + + case "ObjectOptions": + validateMinMaxLogic( + constraints: constraints, + minKey: "minProperties", + maxKey: "maxProperties", + constraintType: "property count" + ) + + default: + break + } + } + + private func validateMinMaxLogic( + constraints: [String: Double], + minKey: String, + maxKey: String, + constraintType: String + ) { + guard let minValue = constraints[minKey], + let maxValue = constraints[maxKey] + else { return } + + if minValue > maxValue { + let diagnostic = Diagnostic( + node: propertyName, + message: SchemaOptionsMismatchDiagnostic.minGreaterThanMax( + propertyName: propertyName.text, + minKey: minKey, + minValue: minValue, + maxKey: maxKey, + maxValue: maxValue, + constraintType: constraintType + ) + ) + context.diagnose(diagnostic) + } + } + + // MARK: - Constraint Value Validation + + private func validateConstraintValues(_ options: LabeledExprListSyntax, macroName: String) { + let constraints = extractConstraints(from: options) + + // Validate non-negative constraints + let nonNegativeConstraints: [String] + switch macroName { + case "StringOptions": + nonNegativeConstraints = ["minLength", "maxLength"] + case "ArrayOptions": + nonNegativeConstraints = ["minItems", "maxItems", "minContains", "maxContains"] + case "ObjectOptions": + nonNegativeConstraints = ["minProperties", "maxProperties"] + case "NumberOptions": + nonNegativeConstraints = ["multipleOf"] + default: + nonNegativeConstraints = [] + } + + for constraintName in nonNegativeConstraints { + if let value = constraints[constraintName], value < 0 { + let diagnostic = Diagnostic( + node: propertyName, + message: SchemaOptionsMismatchDiagnostic.negativeValue( + propertyName: propertyName.text, + constraintName: constraintName, + value: value + ) + ) + context.diagnose(diagnostic) + } + } + } + + // MARK: - ReadOnly/WriteOnly Validation + + private func validateReadWriteConflict(_ options: LabeledExprListSyntax) { + var hasReadOnly = false + var hasWriteOnly = false + + for option in options { + guard let functionCall = option.expression.as(FunctionCallExprSyntax.self), + let memberAccess = functionCall.calledExpression.as(MemberAccessExprSyntax.self) + else { + continue + } + + let optionName = memberAccess.declName.baseName.text + + if optionName == "readOnly" { + if let boolValue = extractBoolValue(from: functionCall), boolValue { + hasReadOnly = true + } + } else if optionName == "writeOnly" { + if let boolValue = extractBoolValue(from: functionCall), boolValue { + hasWriteOnly = true + } + } + } + + if hasReadOnly && hasWriteOnly { + let diagnostic = Diagnostic( + node: propertyName, + message: SchemaOptionsMismatchDiagnostic.readOnlyAndWriteOnly( + propertyName: propertyName.text + ) + ) + context.diagnose(diagnostic) + } + } + + // MARK: - Duplicate Detection + + private func validateNoDuplicates(_ options: LabeledExprListSyntax, macroName: String) { + var seenOptions: [String: Int] = [:] + + for option in options { + guard let functionCall = option.expression.as(FunctionCallExprSyntax.self), + let memberAccess = functionCall.calledExpression.as(MemberAccessExprSyntax.self) + else { + continue + } + + let optionName = memberAccess.declName.baseName.text + seenOptions[optionName, default: 0] += 1 + } + + for (optionName, count) in seenOptions where count > 1 { + let diagnostic = Diagnostic( + node: propertyName, + message: SchemaOptionsMismatchDiagnostic.duplicateOption( + propertyName: propertyName.text, + optionName: optionName, + count: count + ) + ) + context.diagnose(diagnostic) + } + } + + // MARK: - Helper Methods + + private func extractConstraints(from options: LabeledExprListSyntax) -> [String: Double] { + var constraints: [String: Double] = [:] + + for option in options { + guard let functionCall = option.expression.as(FunctionCallExprSyntax.self), + let memberAccess = functionCall.calledExpression.as(MemberAccessExprSyntax.self) + else { + continue + } + + let constraintName = memberAccess.declName.baseName.text + + // Extract numeric value from first argument + if let firstArg = functionCall.arguments.first, + let value = extractNumericValue(from: firstArg.expression) + { + constraints[constraintName] = value + } + } + + return constraints + } + + private func extractNumericValue(from expr: ExprSyntax) -> Double? { + // Try integer literal + if let intLiteral = expr.as(IntegerLiteralExprSyntax.self), + let value = Double(intLiteral.literal.text) + { + return value + } + + // Try float literal + if let floatLiteral = expr.as(FloatLiteralExprSyntax.self), + let value = Double(floatLiteral.literal.text) + { + return value + } + + // Try negative integer + if let prefixExpr = expr.as(PrefixOperatorExprSyntax.self), + prefixExpr.operator.text == "-", + let intLiteral = prefixExpr.expression.as(IntegerLiteralExprSyntax.self), + let value = Double(intLiteral.literal.text) + { + return -value + } + + // Try negative float + if let prefixExpr = expr.as(PrefixOperatorExprSyntax.self), + prefixExpr.operator.text == "-", + let floatLiteral = prefixExpr.expression.as(FloatLiteralExprSyntax.self), + let value = Double(floatLiteral.literal.text) + { + return -value + } + + return nil + } + + private func extractBoolValue(from functionCall: FunctionCallExprSyntax) -> Bool? { + guard let firstArg = functionCall.arguments.first else { + // No argument means default true for some options + return true + } + + if let boolLiteral = firstArg.expression.as(BooleanLiteralExprSyntax.self) { + return boolLiteral.literal.text == "true" + } + + return nil + } +} + +/// Diagnostic messages for SchemaOptions mismatches +enum SchemaOptionsMismatchDiagnostic: DiagnosticMessage { + case typeMismatch( + macroName: String, + propertyName: String, + expectedType: String, + actualType: String + ) + case minGreaterThanMax( + propertyName: String, + minKey: String, + minValue: Double, + maxKey: String, + maxValue: Double, + constraintType: String + ) + case negativeValue(propertyName: String, constraintName: String, value: Double) + case readOnlyAndWriteOnly(propertyName: String) + case conflictingConstraints( + propertyName: String, + constraint1: String, + constraint2: String, + suggestion: String + ) + case duplicateOption(propertyName: String, optionName: String, count: Int) + + var message: String { + switch self { + case .typeMismatch(let macroName, let propertyName, let expectedType, let actualType): + return """ + @\(macroName) can only be used on \(expectedType) properties, but '\(propertyName)' has type '\(actualType)' + """ + + case .minGreaterThanMax( + let propertyName, + let minKey, + let minValue, + let maxKey, + let maxValue, + let constraintType + ): + let minFormatted = + minValue.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(minValue)) : String(minValue) + let maxFormatted = + maxValue.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(maxValue)) : String(maxValue) + return """ + Property '\(propertyName)' has \(minKey) (\(minFormatted)) greater than \(maxKey) (\(maxFormatted)). \ + This \(constraintType) constraint can never be satisfied. + """ + + case .negativeValue(let propertyName, let constraintName, let value): + let valueFormatted = + value.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(value)) : String(value) + return """ + Property '\(propertyName)' has \(constraintName) with negative value (\(valueFormatted)). \ + This constraint must be non-negative. + """ + + case .readOnlyAndWriteOnly(let propertyName): + return """ + Property '\(propertyName)' cannot be both readOnly and writeOnly + """ + + case .conflictingConstraints(let propertyName, let constraint1, let constraint2, let suggestion): + return """ + Property '\(propertyName)' has both \(constraint1) and \(constraint2) specified. \(suggestion). + """ + + case .duplicateOption(let propertyName, let optionName, let count): + return """ + Property '\(propertyName)' has \(optionName) specified \(count) times. Only the last value will be used. + """ + } + } + + var diagnosticID: MessageID { + switch self { + case .typeMismatch: + return MessageID(domain: "JSONSchemaMacro", id: "schemaOptionsTypeMismatch") + case .minGreaterThanMax: + return MessageID(domain: "JSONSchemaMacro", id: "schemaOptionsMinGreaterThanMax") + case .negativeValue: + return MessageID(domain: "JSONSchemaMacro", id: "schemaOptionsNegativeValue") + case .readOnlyAndWriteOnly: + return MessageID(domain: "JSONSchemaMacro", id: "schemaOptionsReadOnlyAndWriteOnly") + case .conflictingConstraints: + return MessageID(domain: "JSONSchemaMacro", id: "schemaOptionsConflictingConstraints") + case .duplicateOption: + return MessageID(domain: "JSONSchemaMacro", id: "schemaOptionsDuplicate") + } + } + + var severity: DiagnosticSeverity { + switch self { + case .typeMismatch, .minGreaterThanMax, .negativeValue, .readOnlyAndWriteOnly: + return .error + case .conflictingConstraints, .duplicateOption: + return .warning + } + } +} diff --git a/Sources/JSONSchemaMacro/Schemable/SchemableMacro.swift b/Sources/JSONSchemaMacro/Schemable/SchemableMacro.swift index 4ca51909..29a59561 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemableMacro.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemableMacro.swift @@ -97,7 +97,8 @@ public struct SchemableMacro: MemberMacro, ExtensionMacro { let generator = SchemaGenerator( fromStruct: structDecl, keyStrategy: strategyArg, - accessLevel: accessLevel + accessLevel: accessLevel, + context: context ) let schemaDecl = generator.makeSchema() var decls: [DeclSyntax] = [schemaDecl] @@ -116,7 +117,8 @@ public struct SchemableMacro: MemberMacro, ExtensionMacro { let generator = SchemaGenerator( fromClass: classDecl, keyStrategy: strategyArg, - accessLevel: accessLevel + accessLevel: accessLevel, + context: context ) let schemaDecl = generator.makeSchema() var decls: [DeclSyntax] = [schemaDecl] diff --git a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift index efca2650..e7d80e51 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift @@ -1,4 +1,6 @@ +import SwiftDiagnostics import SwiftSyntax +import SwiftSyntaxMacros struct SchemableMember { let identifier: TokenSyntax @@ -47,10 +49,37 @@ struct SchemableMember { ) } + /// Validates schema options and emits diagnostics for invalid configurations + func validateOptions(context: any MacroExpansionContext) { + let diagnostics = SchemaOptionsDiagnostics( + propertyName: identifier, + propertyType: type, + context: context + ) + + // Validate general SchemaOptions + if let schemaOptions = annotationArguments { + diagnostics.validateSchemaOptions(schemaOptions) + } + + // Validate type-specific options + if typeSpecificArguments != nil { + let typeSpecificMacroNames = [ + "NumberOptions", "ArrayOptions", "ObjectOptions", "StringOptions", + ] + for macroName in typeSpecificMacroNames { + if let arguments = attributes.arguments(for: macroName) { + diagnostics.validateTypeSpecificOptions(arguments, macroName: macroName) + } + } + } + } + func generateSchema( keyStrategy: ExprSyntax?, typeName: String, - codingKeys: [String: String]? = nil + codingKeys: [String: String]? = nil, + context: (any MacroExpansionContext)? = nil ) -> CodeBlockItemSyntax? { var codeBlock: CodeBlockItemSyntax switch type.typeInformation() { @@ -62,11 +91,23 @@ struct SchemableMember { if let defaultValue { codeBlock = """ \(codeBlock) - .default(\(defaultValue)) + .default(\(defaultValue.trimmed)) """ } case .schemable(_, let code): codeBlock = code - case .notSupported: return nil + case .notSupported: + // Emit diagnostic for unsupported types + if let context = context { + let diagnostic = Diagnostic( + node: identifier, + message: UnsupportedTypeDiagnostic.propertyTypeNotSupported( + propertyName: identifier.text, + typeName: type.description.trimmingCharacters(in: .whitespaces) + ) + ) + context.diagnose(diagnostic) + } + return nil } var customKey: ExprSyntax? @@ -153,3 +194,30 @@ struct SchemableMember { } } } + +/// Diagnostic messages for unsupported types +enum UnsupportedTypeDiagnostic: DiagnosticMessage { + case propertyTypeNotSupported(propertyName: String, typeName: String) + + var message: String { + switch self { + case .propertyTypeNotSupported(let propertyName, let typeName): + return """ + Property '\(propertyName)' has type '\(typeName)' which is not supported by the @Schemable macro. \ + This property will be excluded from the generated schema, which may cause the schema to not match \ + the memberwise initializer. + """ + } + } + + var diagnosticID: MessageID { + switch self { + case .propertyTypeNotSupported: + return MessageID(domain: "JSONSchemaMacro", id: "unsupportedType") + } + } + + var severity: DiagnosticSeverity { + .warning + } +} diff --git a/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift b/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift index 1ecf735a..843c0352 100644 --- a/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift +++ b/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift @@ -244,11 +244,18 @@ extension VariableDeclSyntax { } .contains(where: { $0 == "ExcludeFromSchema" }) } + + var isStatic: Bool { + modifiers.contains { modifier in + modifier.name.tokenKind == .keyword(.static) + } + } } extension MemberBlockItemListSyntax { func schemableMembers() -> [SchemableMember] { self.compactMap { $0.decl.as(VariableDeclSyntax.self) } + .filter { !$0.isStatic } .flatMap { variableDecl in variableDecl.bindings.map { (variableDecl, $0) } } .filter { $0.0.shouldExcludeFromSchema }.filter { $0.1.isStoredProperty } .compactMap(SchemableMember.init) diff --git a/Tests/JSONSchemaMacroTests/Helpers/MacroExpansion+SwiftTesting.swift b/Tests/JSONSchemaMacroTests/Helpers/MacroExpansion+SwiftTesting.swift index 186c582f..ca1e4ad3 100644 --- a/Tests/JSONSchemaMacroTests/Helpers/MacroExpansion+SwiftTesting.swift +++ b/Tests/JSONSchemaMacroTests/Helpers/MacroExpansion+SwiftTesting.swift @@ -6,6 +6,7 @@ import Testing func assertMacroExpansion( _ originalSource: String, expandedSource expectedExpandedSource: String, + diagnostics expectedDiagnostics: [DiagnosticSpec] = [], macros: [String: Macro.Type], fileID: StaticString = #fileID, filePath: StaticString = #filePath, @@ -15,6 +16,7 @@ func assertMacroExpansion( assertMacroExpansion( originalSource, expandedSource: expectedExpandedSource, + diagnostics: expectedDiagnostics, macroSpecs: macros.mapValues { MacroSpec(type: $0) }, indentationWidth: .spaces(2), failureHandler: { spec in diff --git a/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift b/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift new file mode 100644 index 00000000..c91c47ef --- /dev/null +++ b/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift @@ -0,0 +1,406 @@ +import JSONSchemaMacro +import SwiftSyntaxMacros +import SwiftSyntaxMacrosGenericTestSupport +import Testing + +struct InitializerDiagnosticsTests { + let testMacros: [String: Macro.Type] = [ + "Schemable": SchemableMacro.self + ] + + @Test func propertyWithDefaultValueEmitsDiagnostic() { + assertMacroExpansion( + """ + @Schemable + struct Person { + let name: String + let age: Int = 0 + } + """, + expandedSource: """ + struct Person { + let name: String + let age: Int = 0 + + static var schema: some JSONSchemaComponent { + JSONSchema(Person.init) { + JSONObject { + JSONProperty(key: "name") { + JSONString() + } + .required() + JSONProperty(key: "age") { + JSONInteger() + .default(0) + } + .required() + } + } + } + } + + extension Person: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: + "Property 'age' has a default value which will be excluded from the memberwise initializer", + line: 4, + column: 7, + severity: .warning + ) + ], + macros: testMacros + ) + } + + @Test func allPropertiesWithDefaultValuesNoDiagnostic() { + // When ALL properties have default values, the synthesized init() with no + // parameters is intentional and valid, so no diagnostic should be emitted + assertMacroExpansion( + """ + @Schemable + struct Config { + let host: String = "localhost" + let port: Int = 8080 + let timeout: Double = 30.0 + } + """, + expandedSource: """ + struct Config { + let host: String = "localhost" + let port: Int = 8080 + let timeout: Double = 30.0 + + static var schema: some JSONSchemaComponent { + JSONSchema(Config.init) { + JSONObject { + JSONProperty(key: "host") { + JSONString() + .default("localhost") + } + .required() + JSONProperty(key: "port") { + JSONInteger() + .default(8080) + } + .required() + JSONProperty(key: "timeout") { + JSONNumber() + .default(30.0) + } + .required() + } + } + } + } + + extension Config: Schemable { + } + """, + macros: testMacros + ) + } + + @Test func noDefaultValuesNoDiagnostics() { + assertMacroExpansion( + """ + @Schemable + struct Person { + let name: String + let age: Int + } + """, + expandedSource: """ + struct Person { + let name: String + let age: Int + + static var schema: some JSONSchemaComponent { + JSONSchema(Person.init) { + JSONObject { + JSONProperty(key: "name") { + JSONString() + } + .required() + JSONProperty(key: "age") { + JSONInteger() + } + .required() + } + } + } + } + + extension Person: Schemable { + } + """, + diagnostics: [], + macros: testMacros + ) + } + + @Test func explicitInitParameterOrderMismatch() { + assertMacroExpansion( + """ + @Schemable + struct Person { + let name: String + let age: Int + + init(age: Int, name: String) { + self.name = name + self.age = age + } + } + """, + expandedSource: """ + struct Person { + let name: String + let age: Int + + init(age: Int, name: String) { + self.name = name + self.age = age + } + + static var schema: some JSONSchemaComponent { + JSONSchema(Person.init) { + JSONObject { + JSONProperty(key: "name") { + JSONString() + } + .required() + JSONProperty(key: "age") { + JSONInteger() + } + .required() + } + } + } + } + + extension Person: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: + "Initializer parameter at position 1 is 'age' but schema expects 'name'. The schema will generate properties in a different order than the initializer parameters.", + line: 6, + column: 8, + severity: .error + ), + DiagnosticSpec( + message: + "Initializer parameter at position 2 is 'name' but schema expects 'age'. The schema will generate properties in a different order than the initializer parameters.", + line: 6, + column: 18, + severity: .error + ), + ], + macros: testMacros + ) + } + + @Test func explicitInitTypeMismatch() { + assertMacroExpansion( + """ + @Schemable + struct Product { + let name: String + let price: Double + let quantity: Int + + init(name: String, price: Int, quantity: Double) { + self.name = name + self.price = Double(price) + self.quantity = Int(quantity) + } + } + """, + expandedSource: """ + struct Product { + let name: String + let price: Double + let quantity: Int + + init(name: String, price: Int, quantity: Double) { + self.name = name + self.price = Double(price) + self.quantity = Int(quantity) + } + + static var schema: some JSONSchemaComponent { + JSONSchema(Product.init) { + JSONObject { + JSONProperty(key: "name") { + JSONString() + } + .required() + JSONProperty(key: "price") { + JSONNumber() + } + .required() + JSONProperty(key: "quantity") { + JSONInteger() + } + .required() + } + } + } + } + + extension Product: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: + "Parameter 'price' has type 'Int' but schema expects 'Double'. This type mismatch will cause the generated schema to fail.", + line: 7, + column: 29, + severity: .error + ), + DiagnosticSpec( + message: + "Parameter 'quantity' has type 'Double' but schema expects 'Int'. This type mismatch will cause the generated schema to fail.", + line: 7, + column: 44, + severity: .error + ), + ], + macros: testMacros + ) + } + + @Test func noMatchingInitWithExcludedProperties() { + assertMacroExpansion( + """ + @Schemable + struct Config { + let host: String + let port: Int + + @ExcludeFromSchema + let internalFlag: Bool + + init(host: String, port: Int, internalFlag: Bool) { + self.host = host + self.port = port + self.internalFlag = internalFlag + } + } + """, + expandedSource: """ + struct Config { + let host: String + let port: Int + + @ExcludeFromSchema + let internalFlag: Bool + + init(host: String, port: Int, internalFlag: Bool) { + self.host = host + self.port = port + self.internalFlag = internalFlag + } + + static var schema: some JSONSchemaComponent { + JSONSchema(Config.init) { + JSONObject { + JSONProperty(key: "host") { + JSONString() + } + .required() + JSONProperty(key: "port") { + JSONInteger() + } + .required() + } + } + } + } + + extension Config: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: """ + Type 'Config' has explicit initializers, but none match the expected schema signature. + + Expected: init(host: String, port: Int) + + Available initializers: + - init(host: String, port: Int, internalFlag: Bool) + + Note: The following properties are excluded from the schema using @ExcludeFromSchema: 'internalFlag' + These will still be present in the memberwise initializer but not in the schema. + + The generated schema expects JSONSchema(Config.init) to use an initializer that matches all schema properties. Consider adding a matching initializer or adjusting the schema properties. + """, + line: 1, + column: 1, + severity: .error + ) + ], + macros: testMacros + ) + } + + @Test func matchingExplicitInitWithExcludedProperties() { + assertMacroExpansion( + """ + @Schemable + struct Config { + let host: String + let port: Int + + @ExcludeFromSchema + let internalFlag: Bool = false + + init(host: String, port: Int) { + self.host = host + self.port = port + } + } + """, + expandedSource: """ + struct Config { + let host: String + let port: Int + + @ExcludeFromSchema + let internalFlag: Bool = false + + init(host: String, port: Int) { + self.host = host + self.port = port + } + + static var schema: some JSONSchemaComponent { + JSONSchema(Config.init) { + JSONObject { + JSONProperty(key: "host") { + JSONString() + } + .required() + JSONProperty(key: "port") { + JSONInteger() + } + .required() + } + } + } + } + + extension Config: Schemable { + } + """, + diagnostics: [], + macros: testMacros + ) + } +} diff --git a/Tests/JSONSchemaMacroTests/SchemaOptionsDiagnosticsTests.swift b/Tests/JSONSchemaMacroTests/SchemaOptionsDiagnosticsTests.swift new file mode 100644 index 00000000..a07cddc6 --- /dev/null +++ b/Tests/JSONSchemaMacroTests/SchemaOptionsDiagnosticsTests.swift @@ -0,0 +1,611 @@ +import JSONSchemaMacro +import SwiftSyntaxMacros +import SwiftSyntaxMacrosGenericTestSupport +import Testing + +struct SchemaOptionsDiagnosticsTests { + let testMacros: [String: Macro.Type] = [ + "Schemable": SchemableMacro.self, + "SchemaOptions": SchemaOptionsMacro.self, + "StringOptions": StringOptionsMacro.self, + "NumberOptions": NumberOptionsMacro.self, + "ArrayOptions": ArrayOptionsMacro.self, + "ObjectOptions": ObjectOptionsMacro.self, + ] + + // MARK: - Type Mismatch Tests + + @Test func stringOptionsOnIntProperty() { + assertMacroExpansion( + """ + @Schemable + struct Person { + @StringOptions(.minLength(5)) + let age: Int + } + """, + expandedSource: """ + struct Person { + let age: Int + + static var schema: some JSONSchemaComponent { + JSONSchema(Person.init) { + JSONObject { + JSONProperty(key: "age") { + JSONInteger() + .minLength(5) + } + .required() + } + } + } + } + + extension Person: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@StringOptions can only be used on String properties, but 'age' has type 'Int'", + line: 4, + column: 7, + severity: .error + ) + ], + macros: testMacros + ) + } + + @Test func numberOptionsOnStringProperty() { + assertMacroExpansion( + """ + @Schemable + struct Product { + @NumberOptions(.minimum(0)) + let name: String + } + """, + expandedSource: """ + struct Product { + let name: String + + static var schema: some JSONSchemaComponent { + JSONSchema(Product.init) { + JSONObject { + JSONProperty(key: "name") { + JSONString() + .minimum(0) + } + .required() + } + } + } + } + + extension Product: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: + "@NumberOptions can only be used on numeric (Int, Double, etc.) properties, but 'name' has type 'String'", + line: 4, + column: 7, + severity: .error + ) + ], + macros: testMacros + ) + } + + @Test func arrayOptionsOnNonArrayProperty() { + assertMacroExpansion( + """ + @Schemable + struct Data { + @ArrayOptions(.minItems(1)) + let count: Int + } + """, + expandedSource: """ + struct Data { + let count: Int + + static var schema: some JSONSchemaComponent { + JSONSchema(Data.init) { + JSONObject { + JSONProperty(key: "count") { + JSONInteger() + .minItems(1) + } + .required() + } + } + } + } + + extension Data: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@ArrayOptions can only be used on Array properties, but 'count' has type 'Int'", + line: 4, + column: 7, + severity: .error + ) + ], + macros: testMacros + ) + } + + // MARK: - Min > Max Constraint Tests + + @Test func stringMinLengthGreaterThanMaxLength() { + assertMacroExpansion( + """ + @Schemable + struct User { + @StringOptions(.minLength(10), .maxLength(5)) + let username: String + } + """, + expandedSource: """ + struct User { + let username: String + + static var schema: some JSONSchemaComponent { + JSONSchema(User.init) { + JSONObject { + JSONProperty(key: "username") { + JSONString() + .minLength(10) + .maxLength(5) + } + .required() + } + } + } + } + + extension User: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: + "Property 'username' has minLength (10) greater than maxLength (5). This string length constraint can never be satisfied.", + line: 4, + column: 7, + severity: .error + ) + ], + macros: testMacros + ) + } + + @Test func numberMinimumGreaterThanMaximum() { + assertMacroExpansion( + """ + @Schemable + struct Temperature { + @NumberOptions(.minimum(100), .maximum(50)) + let celsius: Double + } + """, + expandedSource: """ + struct Temperature { + let celsius: Double + + static var schema: some JSONSchemaComponent { + JSONSchema(Temperature.init) { + JSONObject { + JSONProperty(key: "celsius") { + JSONNumber() + .minimum(100) + .maximum(50) + } + .required() + } + } + } + } + + extension Temperature: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: + "Property 'celsius' has minimum (100) greater than maximum (50). This value constraint can never be satisfied.", + line: 4, + column: 7, + severity: .error + ) + ], + macros: testMacros + ) + } + + @Test func arrayMinItemsGreaterThanMaxItems() { + assertMacroExpansion( + """ + @Schemable + struct Collection { + @ArrayOptions(.minItems(10), .maxItems(5)) + let items: [String] + } + """, + expandedSource: """ + struct Collection { + let items: [String] + + static var schema: some JSONSchemaComponent { + JSONSchema(Collection.init) { + JSONObject { + JSONProperty(key: "items") { + JSONArray { + JSONString() + } + .minItems(10) + .maxItems(5) + } + .required() + } + } + } + } + + extension Collection: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: + "Property 'items' has minItems (10) greater than maxItems (5). This array size constraint can never be satisfied.", + line: 4, + column: 7, + severity: .error + ) + ], + macros: testMacros + ) + } + + // MARK: - Negative Value Tests + + @Test func negativeMinLength() { + assertMacroExpansion( + """ + @Schemable + struct Data { + @StringOptions(.minLength(-5)) + let text: String + } + """, + expandedSource: """ + struct Data { + let text: String + + static var schema: some JSONSchemaComponent { + JSONSchema(Data.init) { + JSONObject { + JSONProperty(key: "text") { + JSONString() + .minLength(-5) + } + .required() + } + } + } + } + + extension Data: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: + "Property 'text' has minLength with negative value (-5). This constraint must be non-negative.", + line: 4, + column: 7, + severity: .error + ) + ], + macros: testMacros + ) + } + + @Test func negativeMinItems() { + assertMacroExpansion( + """ + @Schemable + struct List { + @ArrayOptions(.minItems(-1)) + let values: [Int] + } + """, + expandedSource: """ + struct List { + let values: [Int] + + static var schema: some JSONSchemaComponent { + JSONSchema(List.init) { + JSONObject { + JSONProperty(key: "values") { + JSONArray { + JSONInteger() + } + .minItems(-1) + } + .required() + } + } + } + } + + extension List: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: + "Property 'values' has minItems with negative value (-1). This constraint must be non-negative.", + line: 4, + column: 7, + severity: .error + ) + ], + macros: testMacros + ) + } + + // MARK: - ReadOnly/WriteOnly Conflict Tests + + @Test func readOnlyAndWriteOnlyConflict() { + assertMacroExpansion( + """ + @Schemable + struct Data { + @SchemaOptions(.readOnly(true), .writeOnly(true)) + let value: String + } + """, + expandedSource: """ + struct Data { + let value: String + + static var schema: some JSONSchemaComponent { + JSONSchema(Data.init) { + JSONObject { + JSONProperty(key: "value") { + JSONString() + .readOnly(true) + .writeOnly(true) + } + .required() + } + } + } + } + + extension Data: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: "Property 'value' cannot be both readOnly and writeOnly", + line: 4, + column: 7, + severity: .error + ) + ], + macros: testMacros + ) + } + + // MARK: - Conflicting Constraint Tests + + @Test func minimumAndExclusiveMinimumConflict() { + assertMacroExpansion( + """ + @Schemable + struct Range { + @NumberOptions(.minimum(0), .exclusiveMinimum(0)) + let value: Double + } + """, + expandedSource: """ + struct Range { + let value: Double + + static var schema: some JSONSchemaComponent { + JSONSchema(Range.init) { + JSONObject { + JSONProperty(key: "value") { + JSONNumber() + .minimum(0) + .exclusiveMinimum(0) + } + .required() + } + } + } + } + + extension Range: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: + "Property 'value' has both minimum and exclusiveMinimum specified. Use only one of minimum or exclusiveMinimum.", + line: 4, + column: 7, + severity: .warning + ) + ], + macros: testMacros + ) + } + + @Test func maximumAndExclusiveMaximumConflict() { + assertMacroExpansion( + """ + @Schemable + struct Range { + @NumberOptions(.maximum(100), .exclusiveMaximum(100)) + let value: Double + } + """, + expandedSource: """ + struct Range { + let value: Double + + static var schema: some JSONSchemaComponent { + JSONSchema(Range.init) { + JSONObject { + JSONProperty(key: "value") { + JSONNumber() + .maximum(100) + .exclusiveMaximum(100) + } + .required() + } + } + } + } + + extension Range: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: + "Property 'value' has both maximum and exclusiveMaximum specified. Use only one of maximum or exclusiveMaximum.", + line: 4, + column: 7, + severity: .warning + ) + ], + macros: testMacros + ) + } + + // MARK: - Duplicate Option Tests + + @Test func duplicateMinLengthOption() { + assertMacroExpansion( + """ + @Schemable + struct Data { + @StringOptions(.minLength(5), .minLength(10)) + let text: String + } + """, + expandedSource: """ + struct Data { + let text: String + + static var schema: some JSONSchemaComponent { + JSONSchema(Data.init) { + JSONObject { + JSONProperty(key: "text") { + JSONString() + .minLength(5) + .minLength(10) + } + .required() + } + } + } + } + + extension Data: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: + "Property 'text' has minLength specified 2 times. Only the last value will be used.", + line: 4, + column: 7, + severity: .warning + ) + ], + macros: testMacros + ) + } + + // MARK: - Valid Usage (No Diagnostics) + + @Test func validStringOptions() { + assertMacroExpansion( + """ + @Schemable + struct User { + @StringOptions(.minLength(5), .maxLength(20)) + let username: String + } + """, + expandedSource: """ + struct User { + let username: String + + static var schema: some JSONSchemaComponent { + JSONSchema(User.init) { + JSONObject { + JSONProperty(key: "username") { + JSONString() + .minLength(5) + .maxLength(20) + } + .required() + } + } + } + } + + extension User: Schemable { + } + """, + diagnostics: [], + macros: testMacros + ) + } + + @Test func validNumberOptions() { + assertMacroExpansion( + """ + @Schemable + struct Product { + @NumberOptions(.minimum(0), .maximum(100)) + let price: Double + } + """, + expandedSource: """ + struct Product { + let price: Double + + static var schema: some JSONSchemaComponent { + JSONSchema(Product.init) { + JSONObject { + JSONProperty(key: "price") { + JSONNumber() + .minimum(0) + .maximum(100) + } + .required() + } + } + } + } + + extension Product: Schemable { + } + """, + diagnostics: [], + macros: testMacros + ) + } +} diff --git a/Tests/JSONSchemaMacroTests/SimpleDiagnosticsTests.swift b/Tests/JSONSchemaMacroTests/SimpleDiagnosticsTests.swift new file mode 100644 index 00000000..98ae2bc1 --- /dev/null +++ b/Tests/JSONSchemaMacroTests/SimpleDiagnosticsTests.swift @@ -0,0 +1,14 @@ +import JSONSchemaMacro +import SwiftSyntaxMacros +import Testing + +struct SimpleDiagnosticsTests { + let testMacros: [String: Macro.Type] = [ + "Schemable": SchemableMacro.self + ] + + @Test func simpleTest() { + // Just ensure the macro can be created and runs + #expect(testMacros["Schemable"] != nil) + } +} diff --git a/Tests/JSONSchemaMacroTests/UnsupportedTypeDiagnosticsTests.swift b/Tests/JSONSchemaMacroTests/UnsupportedTypeDiagnosticsTests.swift new file mode 100644 index 00000000..55389d80 --- /dev/null +++ b/Tests/JSONSchemaMacroTests/UnsupportedTypeDiagnosticsTests.swift @@ -0,0 +1,260 @@ +import JSONSchemaMacro +import SwiftSyntaxMacros +import SwiftSyntaxMacrosGenericTestSupport +import Testing + +struct UnsupportedTypeDiagnosticsTests { + let testMacros: [String: Macro.Type] = [ + "Schemable": SchemableMacro.self + ] + + @Test func functionTypeNotSupported() { + assertMacroExpansion( + """ + @Schemable + struct Handler { + let name: String + let callback: () -> Void + } + """, + expandedSource: """ + struct Handler { + let name: String + let callback: () -> Void + + static var schema: some JSONSchemaComponent { + JSONSchema(Handler.init) { + JSONObject { + JSONProperty(key: "name") { + JSONString() + } + .required() + } + } + } + } + + extension Handler: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: """ + Property 'callback' has type '() -> Void' which is not supported by the @Schemable macro. \ + This property will be excluded from the generated schema, which may cause the schema to not match \ + the memberwise initializer. + """, + line: 4, + column: 7, + severity: .warning + ) + ], + macros: testMacros + ) + } + + @Test func tupleTypeNotSupported() { + assertMacroExpansion( + """ + @Schemable + struct Coordinates { + let name: String + let position: (x: Int, y: Int) + } + """, + expandedSource: """ + struct Coordinates { + let name: String + let position: (x: Int, y: Int) + + static var schema: some JSONSchemaComponent { + JSONSchema(Coordinates.init) { + JSONObject { + JSONProperty(key: "name") { + JSONString() + } + .required() + } + } + } + } + + extension Coordinates: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: """ + Property 'position' has type '(x: Int, y: Int)' which is not supported by the @Schemable macro. \ + This property will be excluded from the generated schema, which may cause the schema to not match \ + the memberwise initializer. + """, + line: 4, + column: 7, + severity: .warning + ) + ], + macros: testMacros + ) + } + + @Test func metatypeNotSupported() { + assertMacroExpansion( + """ + @Schemable + struct Container { + let name: String + let type: Any.Type + } + """, + expandedSource: """ + struct Container { + let name: String + let type: Any.Type + + static var schema: some JSONSchemaComponent { + JSONSchema(Container.init) { + JSONObject { + JSONProperty(key: "name") { + JSONString() + } + .required() + } + } + } + } + + extension Container: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: """ + Property 'type' has type 'Any.Type' which is not supported by the @Schemable macro. \ + This property will be excluded from the generated schema, which may cause the schema to not match \ + the memberwise initializer. + """, + line: 4, + column: 7, + severity: .warning + ) + ], + macros: testMacros + ) + } + + @Test func multipleUnsupportedProperties() { + assertMacroExpansion( + """ + @Schemable + struct Mixed { + let name: String + let callback: () -> Void + let position: (Int, Int) + let age: Int + } + """, + expandedSource: """ + struct Mixed { + let name: String + let callback: () -> Void + let position: (Int, Int) + let age: Int + + static var schema: some JSONSchemaComponent { + JSONSchema(Mixed.init) { + JSONObject { + JSONProperty(key: "name") { + JSONString() + } + .required() + JSONProperty(key: "age") { + JSONInteger() + } + .required() + } + } + } + } + + extension Mixed: Schemable { + } + """, + diagnostics: [ + DiagnosticSpec( + message: """ + Property 'callback' has type '() -> Void' which is not supported by the @Schemable macro. \ + This property will be excluded from the generated schema, which may cause the schema to not match \ + the memberwise initializer. + """, + line: 4, + column: 7, + severity: .warning + ), + DiagnosticSpec( + message: """ + Property 'position' has type '(Int, Int)' which is not supported by the @Schemable macro. \ + This property will be excluded from the generated schema, which may cause the schema to not match \ + the memberwise initializer. + """, + line: 5, + column: 7, + severity: .warning + ), + ], + macros: testMacros + ) + } + + @Test func supportedTypesNoWarning() { + assertMacroExpansion( + """ + @Schemable + struct Person { + let name: String + let age: Int + let score: Double + let tags: [String] + } + """, + expandedSource: """ + struct Person { + let name: String + let age: Int + let score: Double + let tags: [String] + + static var schema: some JSONSchemaComponent { + JSONSchema(Person.init) { + JSONObject { + JSONProperty(key: "name") { + JSONString() + } + .required() + JSONProperty(key: "age") { + JSONInteger() + } + .required() + JSONProperty(key: "score") { + JSONNumber() + } + .required() + JSONProperty(key: "tags") { + JSONArray { + JSONString() + } + } + .required() + } + } + } + } + + extension Person: Schemable { + } + """, + diagnostics: [], + macros: testMacros + ) + } +}