From 92258d57b7124c91feb887a1dab15a07a5b7f0f0 Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Mon, 3 Nov 2025 20:41:55 +0100 Subject: [PATCH 01/14] Add comprehensive initializer diagnostics for @Schemable macro This change adds compile-time validation to catch common issues where the generated schema doesn't match the memberwise initializer, preventing confusing compiler errors. Diagnostics implemented: - Warning when properties have default values (excluded from init) - Error when explicit init has wrong parameter order - Error when explicit init has parameter type mismatches - Error when no matching init is found for schema Key changes: - New InitializerDiagnostics.swift with all validation logic - SchemaGenerator now emits diagnostics during macro expansion - Fixed bug where inline comments were captured in default values - Comprehensive tests using assertMacroExpansion All diagnostics use clear, actionable error messages that explain the problem and suggest fixes. --- .../Schemable/InitializerDiagnostics.swift | 252 +++++++++++ .../Schemable/SchemaGenerator.swift | 25 +- .../Schemable/SchemableMacro.swift | 6 +- .../Schemable/SchemableMember.swift | 2 +- .../Helpers/MacroExpansion+SwiftTesting.swift | 2 + .../InitializerDiagnosticsTests.swift | 418 ++++++++++++++++++ 6 files changed, 700 insertions(+), 5 deletions(-) create mode 100644 Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift create mode 100644 Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift diff --git a/Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift b/Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift new file mode 100644 index 00000000..08b4b865 --- /dev/null +++ b/Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift @@ -0,0 +1,252 @@ +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, isExcluded: Bool)] { + return members.compactMap { $0.decl.as(VariableDeclSyntax.self) } + .filter { !$0.isStatic } + .flatMap { variableDecl in + variableDecl.bindings.compactMap { binding -> (String, String, Bool)? in + guard let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier, + let type = binding.typeAnnotation?.type, + binding.isStoredProperty + else { return nil } + + let isExcluded = !variableDecl.shouldExcludeFromSchema + return ( + name: identifier.text, + type: type.description.trimmingCharacters(in: .whitespaces), + isExcluded: isExcluded + ) + } + } + } + + /// 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 { + let matches = zip(params, expectedParameters).allSatisfy { param, expected in + let paramName = param.secondName?.text ?? param.firstName.text + return paramName == expected.name + } + if matches { + 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) + } + + // 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, isExcluded: Bool)] + ) { + 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 + for member in schemableMembers { + if member.defaultValue != nil { + 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 debea332..0d7e6233 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,11 +148,22 @@ struct SchemaGenerator { members = structDecl.memberBlock.members attributes = structDecl.attributes self.keyStrategy = keyStrategy + self.context = context } func makeSchema() -> DeclSyntax { let schemableMembers = members.schemableMembers() + // Emit diagnostics for potential memberwise init mismatches + if let context = context { + let diagnostics = InitializerDiagnostics( + typeName: name, + members: members, + context: context + ) + diagnostics.emitDiagnostics(for: schemableMembers) + } + let statements = schemableMembers.compactMap { $0.generateSchema(keyStrategy: keyStrategy, typeName: name.text) } 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 67bcf2ec..7275a094 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift @@ -58,7 +58,7 @@ struct SchemableMember { if let defaultValue { codeBlock = """ \(codeBlock) - .default(\(defaultValue)) + .default(\(defaultValue.trimmed)) """ } case .schemable(_, let code): codeBlock = code 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..b3298183 --- /dev/null +++ b/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift @@ -0,0 +1,418 @@ +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() + } + .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 multiplePropertiesWithDefaultValuesEmitsDiagnostic() { + 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 { + } + """, + diagnostics: [ + DiagnosticSpec( + message: "Property 'host' has a default value which will be excluded from the memberwise initializer", + line: 3, + column: 7, + severity: .warning + ), + DiagnosticSpec( + message: "Property 'port' has a default value which will be excluded from the memberwise initializer", + line: 4, + column: 7, + severity: .warning + ), + DiagnosticSpec( + message: "Property 'timeout' has a default value which will be excluded from the memberwise initializer", + line: 5, + column: 7, + severity: .warning + ) + ], + 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 internal: Bool + + init(host: String, port: Int, internal: Bool) { + self.host = host + self.port = port + self.internal = internal + } + } + """, + expandedSource: """ + struct Config { + let host: String + let port: Int + + @ExcludeFromSchema + let internal: Bool + + init(host: String, port: Int, internal: Bool) { + self.host = host + self.port = port + self.internal = internal + } + + 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, internal: Bool) + + Note: The following properties are excluded from the schema using @ExcludeFromSchema: 'internal' + 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: 2, + column: 8, + severity: .error + ) + ], + macros: testMacros + ) + } + + @Test func matchingExplicitInitWithExcludedProperties() { + assertMacroExpansion( + """ + @Schemable + struct Config { + let host: String + let port: Int + + @ExcludeFromSchema + let internal: Bool = false + + init(host: String, port: Int) { + self.host = host + self.port = port + } + } + """, + expandedSource: """ + struct Config { + let host: String + let port: Int + + @ExcludeFromSchema + let internal: 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 + ) + } +} From 92b9114bbd2506a6002da08a0ef59dddc539b344 Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Mon, 3 Nov 2025 22:59:18 +0100 Subject: [PATCH 02/14] Add SchemaOptions diagnostics to prevent invalid schemas This change adds compile-time validation for @SchemaOptions and type-specific option macros (@StringOptions, @NumberOptions, @ArrayOptions, @ObjectOptions) to catch configuration errors before they create invalid schemas. Diagnostics implemented: - Error when type-specific options don't match property type (e.g., @StringOptions on Int property) - Error when min > max constraints (minLength > maxLength, etc.) - Error when constraint values are negative (minLength < 0) - Error when property is both readOnly and writeOnly - Warning when conflicting constraint types are used (minimum + exclusiveMinimum) - Warning when same option is specified multiple times Key changes: - New SchemaOptionsDiagnostics.swift with all validation logic - SchemableMember.validateOptions() validates options during macro expansion - SchemaGenerator calls validation for each member - Comprehensive tests covering all diagnostic scenarios All validations prevent generation of invalid JSON schemas while providing clear, actionable error messages. --- .../Schemable/SchemaGenerator.swift | 5 + .../Schemable/SchemaOptionsDiagnostics.swift | 453 +++++++++++++ .../Schemable/SchemableMember.swift | 27 + .../SchemaOptionsDiagnosticsTests.swift | 616 ++++++++++++++++++ 4 files changed, 1101 insertions(+) create mode 100644 Sources/JSONSchemaMacro/Schemable/SchemaOptionsDiagnostics.swift create mode 100644 Tests/JSONSchemaMacroTests/SchemaOptionsDiagnosticsTests.swift diff --git a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift index 0d7e6233..9c3ca8d3 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift @@ -162,6 +162,11 @@ struct SchemaGenerator { context: context ) diagnostics.emitDiagnostics(for: schemableMembers) + + // Validate schema options for each member + for member in schemableMembers { + member.validateOptions(context: context) + } } let statements = schemableMembers.compactMap { diff --git a/Sources/JSONSchemaMacro/Schemable/SchemaOptionsDiagnostics.swift b/Sources/JSONSchemaMacro/Schemable/SchemaOptionsDiagnostics.swift new file mode 100644 index 00000000..a6b43339 --- /dev/null +++ b/Sources/JSONSchemaMacro/Schemable/SchemaOptionsDiagnostics.swift @@ -0,0 +1,453 @@ +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/SchemableMember.swift b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift index 7275a094..234901ad 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift @@ -1,4 +1,5 @@ import SwiftSyntax +import SwiftSyntaxMacros struct SchemableMember { let identifier: TokenSyntax @@ -47,6 +48,32 @@ 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 let typeSpecificOptions = typeSpecificArguments { + 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) -> CodeBlockItemSyntax? { var codeBlock: CodeBlockItemSyntax switch type.typeInformation() { diff --git a/Tests/JSONSchemaMacroTests/SchemaOptionsDiagnosticsTests.swift b/Tests/JSONSchemaMacroTests/SchemaOptionsDiagnosticsTests.swift new file mode 100644 index 00000000..da26c2e7 --- /dev/null +++ b/Tests/JSONSchemaMacroTests/SchemaOptionsDiagnosticsTests.swift @@ -0,0 +1,616 @@ +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 { + @StringOptions(.minLength(5)) + 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 { + @NumberOptions(.minimum(0)) + 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 { + @ArrayOptions(.minItems(1)) + 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 { + @StringOptions(.minLength(10), .maxLength(5)) + 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 { + @NumberOptions(.minimum(100), .maximum(50)) + 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 { + @ArrayOptions(.minItems(10), .maxItems(5)) + 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 { + @StringOptions(.minLength(-5)) + 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 { + @ArrayOptions(.minItems(-1)) + 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 { + @SchemaOptions(.readOnly(true), .writeOnly(true)) + let value: String + + static var schema: some JSONSchemaComponent { + JSONSchema(Data.init) { + JSONObject { + JSONProperty(key: "value") { + JSONString() + } + .required() + .readOnly(true) + .writeOnly(true) + } + } + } + } + + 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 { + @NumberOptions(.minimum(0), .exclusiveMinimum(0)) + 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 { + @NumberOptions(.maximum(100), .exclusiveMaximum(100)) + 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 { + @StringOptions(.minLength(5), .minLength(10)) + 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 { + @StringOptions(.minLength(5), .maxLength(20)) + 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 { + @NumberOptions(.minimum(0), .maximum(100)) + 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 + ) + } +} From 4cf0aa016a2a56674e69376a91f520b60ff7feb1 Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Mon, 3 Nov 2025 23:24:19 +0100 Subject: [PATCH 03/14] Add diagnostic for unsupported property types This change adds a warning diagnostic when properties have types that are not supported by the @Schemable macro and will be silently excluded from schema generation. The diagnostic catches: - Function types: () -> Void, (Int) -> String, etc. - Tuple types: (Int, Int), (x: Int, y: Int), etc. - Metatypes: Any.Type, String.Type, etc. - Other unsupported Swift types This prevents silent failures where properties disappear from the schema without warning, which would have made issues like the MemberType bug (qualified type names like Weather.Condition) much more obvious. Key changes: - SchemableMember.generateSchema() now accepts optional context parameter - Emits warning when type.typeInformation() returns .notSupported - SchemaGenerator passes context to generateSchema() - New UnsupportedTypeDiagnostic with clear, actionable message - 5 comprehensive tests covering various unsupported type scenarios Example diagnostic: 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. This complements existing initializer diagnostics by catching silent schema generation issues at compile time. --- .../Schemable/SchemaGenerator.swift | 2 +- .../Schemable/SchemableMember.swift | 44 ++- .../UnsupportedTypeDiagnosticsTests.swift | 260 ++++++++++++++++++ 3 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 Tests/JSONSchemaMacroTests/UnsupportedTypeDiagnosticsTests.swift diff --git a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift index 9c3ca8d3..7fdbae04 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift @@ -170,7 +170,7 @@ struct SchemaGenerator { } let statements = schemableMembers.compactMap { - $0.generateSchema(keyStrategy: keyStrategy, typeName: name.text) + $0.generateSchema(keyStrategy: keyStrategy, typeName: name.text, context: context) } var codeBlockItem: CodeBlockItemSyntax = diff --git a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift index 234901ad..4ee83a8d 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift @@ -1,3 +1,4 @@ +import SwiftDiagnostics import SwiftSyntax import SwiftSyntaxMacros @@ -74,7 +75,7 @@ struct SchemableMember { } } - func generateSchema(keyStrategy: ExprSyntax?, typeName: String) -> CodeBlockItemSyntax? { + func generateSchema(keyStrategy: ExprSyntax?, typeName: String, context: (any MacroExpansionContext)? = nil) -> CodeBlockItemSyntax? { var codeBlock: CodeBlockItemSyntax switch type.typeInformation() { case .primitive(_, let code): @@ -89,7 +90,19 @@ struct SchemableMember { """ } 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? @@ -170,3 +183,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/Tests/JSONSchemaMacroTests/UnsupportedTypeDiagnosticsTests.swift b/Tests/JSONSchemaMacroTests/UnsupportedTypeDiagnosticsTests.swift new file mode 100644 index 00000000..22e7fce7 --- /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 + ) + } +} From df35fdeb93558792aba1727bcb505580a7043e29 Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Mon, 3 Nov 2025 23:37:12 +0100 Subject: [PATCH 04/14] lint --- .../Schemable/InitializerDiagnostics.swift | 70 +++++++++------ .../Schemable/SchemaOptionsDiagnostics.swift | 88 ++++++++++++++----- .../Schemable/SchemableMember.swift | 6 +- .../InitializerDiagnosticsTests.swift | 30 ++++--- .../SchemaOptionsDiagnosticsTests.swift | 29 +++--- .../SimpleDiagnosticsTests.swift | 14 +++ .../UnsupportedTypeDiagnosticsTests.swift | 2 +- 7 files changed, 165 insertions(+), 74 deletions(-) create mode 100644 Tests/JSONSchemaMacroTests/SimpleDiagnosticsTests.swift diff --git a/Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift b/Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift index 08b4b865..00ea01e2 100644 --- a/Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift +++ b/Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift @@ -18,13 +18,17 @@ struct InitializerDiagnostics { // 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)) + ( + 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) { + if let memberWiseInit = findMatchingInit(explicitInits, expectedParameters: expectedParameters) + { // Found a matching explicit init - validate it matches exactly validateInitParameters(memberWiseInit, expectedParameters: expectedParameters) } else if !explicitInits.isEmpty { @@ -43,13 +47,13 @@ struct InitializerDiagnostics { /// Gets all stored properties including those marked with @ExcludeFromSchema private func getAllStoredProperties() -> [(name: String, type: String, isExcluded: Bool)] { - return members.compactMap { $0.decl.as(VariableDeclSyntax.self) } + members.compactMap { $0.decl.as(VariableDeclSyntax.self) } .filter { !$0.isStatic } .flatMap { variableDecl in variableDecl.bindings.compactMap { binding -> (String, String, Bool)? in guard let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier, - let type = binding.typeAnnotation?.type, - binding.isStoredProperty + let type = binding.typeAnnotation?.type, + binding.isStoredProperty else { return nil } let isExcluded = !variableDecl.shouldExcludeFromSchema @@ -70,10 +74,11 @@ struct InitializerDiagnostics { for initDecl in inits { let params = initDecl.signature.parameterClause.parameters if params.count == expectedParameters.count { - let matches = zip(params, expectedParameters).allSatisfy { param, expected in - let paramName = param.secondName?.text ?? param.firstName.text - return paramName == expected.name - } + let matches = zip(params, expectedParameters) + .allSatisfy { param, expected in + let paramName = param.secondName?.text ?? param.firstName.text + return paramName == expected.name + } if matches { return initDecl } @@ -126,14 +131,17 @@ struct InitializerDiagnostics { availableInits: [InitializerDeclSyntax], excludedProperties: [(name: String, type: String, isExcluded: Bool)] ) { - let expectedSignature = expectedParameters.map { "\($0.name): \($0.type)" }.joined(separator: ", ") + 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: ", ") + 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))" } @@ -152,16 +160,14 @@ struct InitializerDiagnostics { /// Validates requirements for synthesized memberwise init private func validateSynthesizedInitRequirements(schemableMembers: [SchemableMember]) { // Check for properties with default values - these won't be in synthesized init - for member in schemableMembers { - if member.defaultValue != nil { - let diagnostic = Diagnostic( - node: member.identifier, - message: InitializerMismatchDiagnostic.propertyHasDefault( - propertyName: member.identifier.text - ) + for member in schemableMembers where member.defaultValue != nil { + let diagnostic = Diagnostic( + node: member.identifier, + message: InitializerMismatchDiagnostic.propertyHasDefault( + propertyName: member.identifier.text ) - context.diagnose(diagnostic) - } + ) + context.diagnose(diagnostic) } } } @@ -181,7 +187,8 @@ enum InitializerMismatchDiagnostic: DiagnosticMessage { var message: String { switch self { case .propertyHasDefault(let propertyName): - return "Property '\(propertyName)' has a default value which will be excluded from the memberwise initializer" + return + "Property '\(propertyName)' has a default value which will be excluded from the memberwise initializer" case .parameterOrderMismatch(let position, let expectedName, let actualName): return """ @@ -195,7 +202,12 @@ enum InitializerMismatchDiagnostic: DiagnosticMessage { This type mismatch will cause the generated schema to fail. """ - case .noMatchingInit(let typeName, let expectedSignature, let availableInits, let excludedProperties): + case .noMatchingInit( + let typeName, + let expectedSignature, + let availableInits, + let excludedProperties + ): var msg = """ Type '\(typeName)' has explicit initializers, but none match the expected schema signature. @@ -212,9 +224,9 @@ enum InitializerMismatchDiagnostic: DiagnosticMessage { 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. - """ + 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 += """ diff --git a/Sources/JSONSchemaMacro/Schemable/SchemaOptionsDiagnostics.swift b/Sources/JSONSchemaMacro/Schemable/SchemaOptionsDiagnostics.swift index a6b43339..bbb967a6 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemaOptionsDiagnostics.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemaOptionsDiagnostics.swift @@ -49,7 +49,11 @@ struct SchemaOptionsDiagnostics { case .primitive(.int, _), .primitive(.double, _): break // Valid numeric types default: - emitTypeMismatch(macroName: macroName, expectedType: "numeric (Int, Double, etc.)", actualType: typeInfo) + emitTypeMismatch( + macroName: macroName, + expectedType: "numeric (Int, Double, etc.)", + actualType: typeInfo + ) } case "ArrayOptions": @@ -78,7 +82,11 @@ struct SchemaOptionsDiagnostics { } } - private func emitTypeMismatch(macroName: String, expectedType: String, actualType: TypeSyntax.TypeInformation) { + private func emitTypeMismatch( + macroName: String, + expectedType: String, + actualType: TypeSyntax.TypeInformation + ) { let actualTypeString: String switch actualType { case .primitive(let primitive, _): @@ -191,7 +199,8 @@ struct SchemaOptionsDiagnostics { constraintType: String ) { guard let minValue = constraints[minKey], - let maxValue = constraints[maxKey] else { return } + let maxValue = constraints[maxKey] + else { return } if minValue > maxValue { let diagnostic = Diagnostic( @@ -252,7 +261,8 @@ struct SchemaOptionsDiagnostics { for option in options { guard let functionCall = option.expression.as(FunctionCallExprSyntax.self), - let memberAccess = functionCall.calledExpression.as(MemberAccessExprSyntax.self) else { + let memberAccess = functionCall.calledExpression.as(MemberAccessExprSyntax.self) + else { continue } @@ -287,7 +297,8 @@ struct SchemaOptionsDiagnostics { for option in options { guard let functionCall = option.expression.as(FunctionCallExprSyntax.self), - let memberAccess = functionCall.calledExpression.as(MemberAccessExprSyntax.self) else { + let memberAccess = functionCall.calledExpression.as(MemberAccessExprSyntax.self) + else { continue } @@ -315,7 +326,8 @@ struct SchemaOptionsDiagnostics { for option in options { guard let functionCall = option.expression.as(FunctionCallExprSyntax.self), - let memberAccess = functionCall.calledExpression.as(MemberAccessExprSyntax.self) else { + let memberAccess = functionCall.calledExpression.as(MemberAccessExprSyntax.self) + else { continue } @@ -323,7 +335,8 @@ struct SchemaOptionsDiagnostics { // Extract numeric value from first argument if let firstArg = functionCall.arguments.first, - let value = extractNumericValue(from: firstArg.expression) { + let value = extractNumericValue(from: firstArg.expression) + { constraints[constraintName] = value } } @@ -334,29 +347,33 @@ struct SchemaOptionsDiagnostics { private func extractNumericValue(from expr: ExprSyntax) -> Double? { // Try integer literal if let intLiteral = expr.as(IntegerLiteralExprSyntax.self), - let value = Double(intLiteral.literal.text) { + let value = Double(intLiteral.literal.text) + { return value } // Try float literal if let floatLiteral = expr.as(FloatLiteralExprSyntax.self), - let value = Double(floatLiteral.literal.text) { + 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) { + 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) { + prefixExpr.operator.text == "-", + let floatLiteral = prefixExpr.expression.as(FloatLiteralExprSyntax.self), + let value = Double(floatLiteral.literal.text) + { return -value } @@ -379,11 +396,28 @@ struct SchemaOptionsDiagnostics { /// 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 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 conflictingConstraints( + propertyName: String, + constraint1: String, + constraint2: String, + suggestion: String + ) case duplicateOption(propertyName: String, optionName: String, count: Int) var message: String { @@ -393,16 +427,26 @@ enum SchemaOptionsMismatchDiagnostic: DiagnosticMessage { @\(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) + 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) + 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. diff --git a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift index 4ee83a8d..56e48ef9 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift @@ -75,7 +75,11 @@ struct SchemableMember { } } - func generateSchema(keyStrategy: ExprSyntax?, typeName: String, context: (any MacroExpansionContext)? = nil) -> CodeBlockItemSyntax? { + func generateSchema( + keyStrategy: ExprSyntax?, + typeName: String, + context: (any MacroExpansionContext)? = nil + ) -> CodeBlockItemSyntax? { var codeBlock: CodeBlockItemSyntax switch type.typeInformation() { case .primitive(_, let code): diff --git a/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift b/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift index b3298183..fe5bd022 100644 --- a/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift +++ b/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift @@ -43,7 +43,8 @@ struct InitializerDiagnosticsTests { """, diagnostics: [ DiagnosticSpec( - message: "Property 'age' has a default value which will be excluded from the memberwise initializer", + message: + "Property 'age' has a default value which will be excluded from the memberwise initializer", line: 4, column: 7, severity: .warning @@ -97,23 +98,26 @@ struct InitializerDiagnosticsTests { """, diagnostics: [ DiagnosticSpec( - message: "Property 'host' has a default value which will be excluded from the memberwise initializer", + message: + "Property 'host' has a default value which will be excluded from the memberwise initializer", line: 3, column: 7, severity: .warning ), DiagnosticSpec( - message: "Property 'port' has a default value which will be excluded from the memberwise initializer", + message: + "Property 'port' has a default value which will be excluded from the memberwise initializer", line: 4, column: 7, severity: .warning ), DiagnosticSpec( - message: "Property 'timeout' has a default value which will be excluded from the memberwise initializer", + message: + "Property 'timeout' has a default value which will be excluded from the memberwise initializer", line: 5, column: 7, severity: .warning - ) + ), ], macros: testMacros ) @@ -202,17 +206,19 @@ struct InitializerDiagnosticsTests { """, 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.", + 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.", + 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 ) @@ -271,17 +277,19 @@ struct InitializerDiagnosticsTests { """, diagnostics: [ DiagnosticSpec( - message: "Parameter 'price' has type 'Int' but schema expects 'Double'. This type mismatch will cause the generated schema to fail.", + 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.", + 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 ) diff --git a/Tests/JSONSchemaMacroTests/SchemaOptionsDiagnosticsTests.swift b/Tests/JSONSchemaMacroTests/SchemaOptionsDiagnosticsTests.swift index da26c2e7..489f7d83 100644 --- a/Tests/JSONSchemaMacroTests/SchemaOptionsDiagnosticsTests.swift +++ b/Tests/JSONSchemaMacroTests/SchemaOptionsDiagnosticsTests.swift @@ -10,7 +10,7 @@ struct SchemaOptionsDiagnosticsTests { "StringOptions": StringOptionsMacro.self, "NumberOptions": NumberOptionsMacro.self, "ArrayOptions": ArrayOptionsMacro.self, - "ObjectOptions": ObjectOptionsMacro.self + "ObjectOptions": ObjectOptionsMacro.self, ] // MARK: - Type Mismatch Tests @@ -89,7 +89,8 @@ struct SchemaOptionsDiagnosticsTests { """, diagnostics: [ DiagnosticSpec( - message: "@NumberOptions can only be used on numeric (Int, Double, etc.) properties, but 'name' has type 'String'", + message: + "@NumberOptions can only be used on numeric (Int, Double, etc.) properties, but 'name' has type 'String'", line: 4, column: 7, severity: .error @@ -176,7 +177,8 @@ struct SchemaOptionsDiagnosticsTests { """, diagnostics: [ DiagnosticSpec( - message: "Property 'username' has minLength (10) greater than maxLength (5). This string length constraint can never be satisfied.", + message: + "Property 'username' has minLength (10) greater than maxLength (5). This string length constraint can never be satisfied.", line: 4, column: 7, severity: .error @@ -219,7 +221,8 @@ struct SchemaOptionsDiagnosticsTests { """, diagnostics: [ DiagnosticSpec( - message: "Property 'celsius' has minimum (100) greater than maximum (50). This value constraint can never be satisfied.", + message: + "Property 'celsius' has minimum (100) greater than maximum (50). This value constraint can never be satisfied.", line: 4, column: 7, severity: .error @@ -264,7 +267,8 @@ struct SchemaOptionsDiagnosticsTests { """, diagnostics: [ DiagnosticSpec( - message: "Property 'items' has minItems (10) greater than maxItems (5). This array size constraint can never be satisfied.", + message: + "Property 'items' has minItems (10) greater than maxItems (5). This array size constraint can never be satisfied.", line: 4, column: 7, severity: .error @@ -308,7 +312,8 @@ struct SchemaOptionsDiagnosticsTests { """, diagnostics: [ DiagnosticSpec( - message: "Property 'text' has minLength with negative value (-5). This constraint must be non-negative.", + message: + "Property 'text' has minLength with negative value (-5). This constraint must be non-negative.", line: 4, column: 7, severity: .error @@ -352,7 +357,8 @@ struct SchemaOptionsDiagnosticsTests { """, diagnostics: [ DiagnosticSpec( - message: "Property 'values' has minItems with negative value (-1). This constraint must be non-negative.", + message: + "Property 'values' has minItems with negative value (-1). This constraint must be non-negative.", line: 4, column: 7, severity: .error @@ -442,7 +448,8 @@ struct SchemaOptionsDiagnosticsTests { """, diagnostics: [ DiagnosticSpec( - message: "Property 'value' has both minimum and exclusiveMinimum specified. Use only one of minimum or exclusiveMinimum.", + message: + "Property 'value' has both minimum and exclusiveMinimum specified. Use only one of minimum or exclusiveMinimum.", line: 4, column: 7, severity: .warning @@ -485,7 +492,8 @@ struct SchemaOptionsDiagnosticsTests { """, diagnostics: [ DiagnosticSpec( - message: "Property 'value' has both maximum and exclusiveMaximum specified. Use only one of maximum or exclusiveMaximum.", + message: + "Property 'value' has both maximum and exclusiveMaximum specified. Use only one of maximum or exclusiveMaximum.", line: 4, column: 7, severity: .warning @@ -530,7 +538,8 @@ struct SchemaOptionsDiagnosticsTests { """, diagnostics: [ DiagnosticSpec( - message: "Property 'text' has minLength specified 2 times. Only the last value will be used.", + message: + "Property 'text' has minLength specified 2 times. Only the last value will be used.", line: 4, column: 7, severity: .warning 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 index 22e7fce7..55389d80 100644 --- a/Tests/JSONSchemaMacroTests/UnsupportedTypeDiagnosticsTests.swift +++ b/Tests/JSONSchemaMacroTests/UnsupportedTypeDiagnosticsTests.swift @@ -200,7 +200,7 @@ struct UnsupportedTypeDiagnosticsTests { line: 5, column: 7, severity: .warning - ) + ), ], macros: testMacros ) From 6e72fc53e737463fca4a4f7fd71c503c84693b2f Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Tue, 4 Nov 2025 00:10:33 +0100 Subject: [PATCH 05/14] Fix unused variable warning in validateOptions --- Sources/JSONSchemaMacro/Schemable/SchemableMember.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift index 56e48ef9..c5513f6d 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift @@ -63,7 +63,7 @@ struct SchemableMember { } // Validate type-specific options - if let typeSpecificOptions = typeSpecificArguments { + if typeSpecificArguments != nil { let typeSpecificMacroNames = [ "NumberOptions", "ArrayOptions", "ObjectOptions", "StringOptions", ] From 2ef24551fd6e74a61be3b58550d337bd0e72c250 Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Tue, 4 Nov 2025 01:30:01 +0100 Subject: [PATCH 06/14] Fix CI test failures - rename 'internal' to 'internalFlag' The keyword 'internal' is reserved in Swift and cannot be used as an identifier. Renamed test property to 'internalFlag' to fix compilation errors in CI. --- .../InitializerDiagnosticsTests.swift | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift b/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift index fe5bd022..85320141 100644 --- a/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift +++ b/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift @@ -304,12 +304,12 @@ struct InitializerDiagnosticsTests { let port: Int @ExcludeFromSchema - let internal: Bool + let internalFlag: Bool - init(host: String, port: Int, internal: Bool) { + init(host: String, port: Int, internalFlag: Bool) { self.host = host self.port = port - self.internal = internal + self.internalFlag = internalFlag } } """, @@ -319,12 +319,12 @@ struct InitializerDiagnosticsTests { let port: Int @ExcludeFromSchema - let internal: Bool + let internalFlag: Bool - init(host: String, port: Int, internal: Bool) { + init(host: String, port: Int, internalFlag: Bool) { self.host = host self.port = port - self.internal = internal + self.internalFlag = internalFlag } static var schema: some JSONSchemaComponent { @@ -354,9 +354,9 @@ struct InitializerDiagnosticsTests { Expected: init(host: String, port: Int) Available initializers: - - init(host: String, port: Int, internal: Bool) + - init(host: String, port: Int, internalFlag: Bool) - Note: The following properties are excluded from the schema using @ExcludeFromSchema: 'internal' + 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. @@ -379,7 +379,7 @@ struct InitializerDiagnosticsTests { let port: Int @ExcludeFromSchema - let internal: Bool = false + let internalFlag: Bool = false init(host: String, port: Int) { self.host = host @@ -393,7 +393,7 @@ struct InitializerDiagnosticsTests { let port: Int @ExcludeFromSchema - let internal: Bool = false + let internalFlag: Bool = false init(host: String, port: Int) { self.host = host From 6dbe878cbf319478e4c6db58ca152347550f549a Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Tue, 4 Nov 2025 01:45:12 +0100 Subject: [PATCH 07/14] Fix default value indentation in macro expansion The .default() call should be at the same indentation level as the component above it (e.g., JSONString()), not indented 2 spaces further. This matches the existing test expectations in SchemableExpansionTests and fixes the 20 test failures on CI in InitializerDiagnosticsTests. --- Sources/JSONSchemaMacro/Schemable/SchemableMember.swift | 6 +++--- .../JSONSchemaMacroTests/InitializerDiagnosticsTests.swift | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift index c5513f6d..13e2b5c1 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift @@ -89,9 +89,9 @@ struct SchemableMember { // In the future, JSONValue types should also be allowed to apply default value if let defaultValue { codeBlock = """ - \(codeBlock) - .default(\(defaultValue.trimmed)) - """ +\(codeBlock) +.default(\(defaultValue.trimmed)) +""" } case .schemable(_, let code): codeBlock = code case .notSupported: diff --git a/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift b/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift index 85320141..d158c5c2 100644 --- a/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift +++ b/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift @@ -75,17 +75,17 @@ struct InitializerDiagnosticsTests { JSONObject { JSONProperty(key: "host") { JSONString() - .default("localhost") + .default("localhost") } .required() JSONProperty(key: "port") { JSONInteger() - .default(8080) + .default(8080) } .required() JSONProperty(key: "timeout") { JSONNumber() - .default(30.0) + .default(30.0) } .required() } From 6ef22adf7125905ee30598f3e7d2ca5466dd2f67 Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Tue, 4 Nov 2025 01:53:02 +0100 Subject: [PATCH 08/14] Fix SchemaOptions method call indentation - Fix SchemaOptionsGenerator to generate method calls at the same indentation level as the component (not 2 spaces more) - Fix test expectations in SchemaOptionsDiagnosticsTests to match correct indentation - Remove attribute macros from expandedSource (they should be consumed during expansion) - Fix readOnly/writeOnly placement to be inside JSONProperty closure This brings SchemaOptions behavior in line with the existing tests in SchemaOptionsTests.swift. --- .../Schemable/SchemaOptionsGenerator.swift | 28 ++++----- .../SchemaOptionsDiagnosticsTests.swift | 60 +++++++------------ 2 files changed, 37 insertions(+), 51 deletions(-) diff --git a/Sources/JSONSchemaMacro/Schemable/SchemaOptionsGenerator.swift b/Sources/JSONSchemaMacro/Schemable/SchemaOptionsGenerator.swift index 23a3a804..edafe108 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemaOptionsGenerator.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemaOptionsGenerator.swift @@ -44,9 +44,9 @@ enum SchemaOptionsGenerator { return applyClosureBasedOption(optionName, closure: closure, to: codeBlockItem) } else if let value = functionCall.arguments.first { return """ - \(codeBlockItem) - .\(raw: optionName)(\(value)) - """ +\(codeBlockItem) +.\(raw: optionName)(\(value)) +""" } return codeBlockItem @@ -65,24 +65,24 @@ enum SchemaOptionsGenerator { let bool = expr.as(BooleanLiteralExprSyntax.self) { return """ - \(codeBlockItem) - .additionalProperties(\(raw: bool.literal.text)) - """ +\(codeBlockItem) +.additionalProperties(\(raw: bool.literal.text)) +""" } // Intentionally fall through to "patternProperties" to handle shared logic. fallthrough case "patternProperties", "propertyNames": return """ - \(codeBlockItem) - .\(raw: optionName) { \(closure.statements) } - // Drop the parse information. Use custom builder if needed. - .map { $0.0 } - """ +\(codeBlockItem) +.\(raw: optionName) { \(closure.statements) } +// Drop the parse information. Use custom builder if needed. +.map { $0.0 } +""" default: return """ - \(codeBlockItem) - .\(raw: optionName) { \(closure.statements) } - """ +\(codeBlockItem) +.\(raw: optionName) { \(closure.statements) } +""" } } } diff --git a/Tests/JSONSchemaMacroTests/SchemaOptionsDiagnosticsTests.swift b/Tests/JSONSchemaMacroTests/SchemaOptionsDiagnosticsTests.swift index 489f7d83..a07cddc6 100644 --- a/Tests/JSONSchemaMacroTests/SchemaOptionsDiagnosticsTests.swift +++ b/Tests/JSONSchemaMacroTests/SchemaOptionsDiagnosticsTests.swift @@ -26,7 +26,6 @@ struct SchemaOptionsDiagnosticsTests { """, expandedSource: """ struct Person { - @StringOptions(.minLength(5)) let age: Int static var schema: some JSONSchemaComponent { @@ -34,7 +33,7 @@ struct SchemaOptionsDiagnosticsTests { JSONObject { JSONProperty(key: "age") { JSONInteger() - .minLength(5) + .minLength(5) } .required() } @@ -68,7 +67,6 @@ struct SchemaOptionsDiagnosticsTests { """, expandedSource: """ struct Product { - @NumberOptions(.minimum(0)) let name: String static var schema: some JSONSchemaComponent { @@ -76,7 +74,7 @@ struct SchemaOptionsDiagnosticsTests { JSONObject { JSONProperty(key: "name") { JSONString() - .minimum(0) + .minimum(0) } .required() } @@ -111,7 +109,6 @@ struct SchemaOptionsDiagnosticsTests { """, expandedSource: """ struct Data { - @ArrayOptions(.minItems(1)) let count: Int static var schema: some JSONSchemaComponent { @@ -119,7 +116,7 @@ struct SchemaOptionsDiagnosticsTests { JSONObject { JSONProperty(key: "count") { JSONInteger() - .minItems(1) + .minItems(1) } .required() } @@ -155,7 +152,6 @@ struct SchemaOptionsDiagnosticsTests { """, expandedSource: """ struct User { - @StringOptions(.minLength(10), .maxLength(5)) let username: String static var schema: some JSONSchemaComponent { @@ -163,8 +159,8 @@ struct SchemaOptionsDiagnosticsTests { JSONObject { JSONProperty(key: "username") { JSONString() - .minLength(10) - .maxLength(5) + .minLength(10) + .maxLength(5) } .required() } @@ -199,7 +195,6 @@ struct SchemaOptionsDiagnosticsTests { """, expandedSource: """ struct Temperature { - @NumberOptions(.minimum(100), .maximum(50)) let celsius: Double static var schema: some JSONSchemaComponent { @@ -207,8 +202,8 @@ struct SchemaOptionsDiagnosticsTests { JSONObject { JSONProperty(key: "celsius") { JSONNumber() - .minimum(100) - .maximum(50) + .minimum(100) + .maximum(50) } .required() } @@ -243,7 +238,6 @@ struct SchemaOptionsDiagnosticsTests { """, expandedSource: """ struct Collection { - @ArrayOptions(.minItems(10), .maxItems(5)) let items: [String] static var schema: some JSONSchemaComponent { @@ -253,8 +247,8 @@ struct SchemaOptionsDiagnosticsTests { JSONArray { JSONString() } - .minItems(10) - .maxItems(5) + .minItems(10) + .maxItems(5) } .required() } @@ -291,7 +285,6 @@ struct SchemaOptionsDiagnosticsTests { """, expandedSource: """ struct Data { - @StringOptions(.minLength(-5)) let text: String static var schema: some JSONSchemaComponent { @@ -299,7 +292,7 @@ struct SchemaOptionsDiagnosticsTests { JSONObject { JSONProperty(key: "text") { JSONString() - .minLength(-5) + .minLength(-5) } .required() } @@ -334,7 +327,6 @@ struct SchemaOptionsDiagnosticsTests { """, expandedSource: """ struct List { - @ArrayOptions(.minItems(-1)) let values: [Int] static var schema: some JSONSchemaComponent { @@ -344,7 +336,7 @@ struct SchemaOptionsDiagnosticsTests { JSONArray { JSONInteger() } - .minItems(-1) + .minItems(-1) } .required() } @@ -381,7 +373,6 @@ struct SchemaOptionsDiagnosticsTests { """, expandedSource: """ struct Data { - @SchemaOptions(.readOnly(true), .writeOnly(true)) let value: String static var schema: some JSONSchemaComponent { @@ -389,10 +380,10 @@ struct SchemaOptionsDiagnosticsTests { JSONObject { JSONProperty(key: "value") { JSONString() - } - .required() .readOnly(true) .writeOnly(true) + } + .required() } } } @@ -426,7 +417,6 @@ struct SchemaOptionsDiagnosticsTests { """, expandedSource: """ struct Range { - @NumberOptions(.minimum(0), .exclusiveMinimum(0)) let value: Double static var schema: some JSONSchemaComponent { @@ -434,8 +424,8 @@ struct SchemaOptionsDiagnosticsTests { JSONObject { JSONProperty(key: "value") { JSONNumber() - .minimum(0) - .exclusiveMinimum(0) + .minimum(0) + .exclusiveMinimum(0) } .required() } @@ -470,7 +460,6 @@ struct SchemaOptionsDiagnosticsTests { """, expandedSource: """ struct Range { - @NumberOptions(.maximum(100), .exclusiveMaximum(100)) let value: Double static var schema: some JSONSchemaComponent { @@ -478,8 +467,8 @@ struct SchemaOptionsDiagnosticsTests { JSONObject { JSONProperty(key: "value") { JSONNumber() - .maximum(100) - .exclusiveMaximum(100) + .maximum(100) + .exclusiveMaximum(100) } .required() } @@ -516,7 +505,6 @@ struct SchemaOptionsDiagnosticsTests { """, expandedSource: """ struct Data { - @StringOptions(.minLength(5), .minLength(10)) let text: String static var schema: some JSONSchemaComponent { @@ -524,8 +512,8 @@ struct SchemaOptionsDiagnosticsTests { JSONObject { JSONProperty(key: "text") { JSONString() - .minLength(5) - .minLength(10) + .minLength(5) + .minLength(10) } .required() } @@ -562,7 +550,6 @@ struct SchemaOptionsDiagnosticsTests { """, expandedSource: """ struct User { - @StringOptions(.minLength(5), .maxLength(20)) let username: String static var schema: some JSONSchemaComponent { @@ -570,8 +557,8 @@ struct SchemaOptionsDiagnosticsTests { JSONObject { JSONProperty(key: "username") { JSONString() - .minLength(5) - .maxLength(20) + .minLength(5) + .maxLength(20) } .required() } @@ -598,7 +585,6 @@ struct SchemaOptionsDiagnosticsTests { """, expandedSource: """ struct Product { - @NumberOptions(.minimum(0), .maximum(100)) let price: Double static var schema: some JSONSchemaComponent { @@ -606,8 +592,8 @@ struct SchemaOptionsDiagnosticsTests { JSONObject { JSONProperty(key: "price") { JSONNumber() - .minimum(0) - .maximum(100) + .minimum(0) + .maximum(100) } .required() } From ca33ffc2e261494ffb006c8fbca10b4313007959 Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Tue, 4 Nov 2025 02:03:45 +0100 Subject: [PATCH 09/14] Fix initializer diagnostics logic and test expectations - Only emit default value warnings for mixed cases (some properties with defaults, some without) rather than all cases with defaults - Fix findMatchingInit to match by parameter name set regardless of order, then validate order separately - Only emit type mismatch errors when parameter names match at that position (avoid spurious errors when order is wrong) - Update test expectations to include .default() in schema output - Rename test to reflect corrected behavior (no diagnostics when all properties have defaults) - Fix diagnostic location expectation This eliminates false positive warnings while still catching real initializer/schema mismatches. --- .../Schemable/InitializerDiagnostics.swift | 59 +++++++++++-------- .../InitializerDiagnosticsTests.swift | 32 ++-------- 2 files changed, 40 insertions(+), 51 deletions(-) diff --git a/Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift b/Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift index 00ea01e2..28bec842 100644 --- a/Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift +++ b/Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift @@ -74,12 +74,12 @@ struct InitializerDiagnostics { for initDecl in inits { let params = initDecl.signature.parameterClause.parameters if params.count == expectedParameters.count { - let matches = zip(params, expectedParameters) - .allSatisfy { param, expected in - let paramName = param.secondName?.text ?? param.firstName.text - return paramName == expected.name - } - if matches { + // 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 } } @@ -108,19 +108,20 @@ struct InitializerDiagnostics { ) ) context.diagnose(diagnostic) - } - - // 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 + } 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) + context.diagnose(diagnostic) + } } } } @@ -160,14 +161,22 @@ struct InitializerDiagnostics { /// Validates requirements for synthesized memberwise init private func validateSynthesizedInitRequirements(schemableMembers: [SchemableMember]) { // Check for properties with default values - these won't be in synthesized init - for member in schemableMembers where member.defaultValue != nil { - let diagnostic = Diagnostic( - node: member.identifier, - message: InitializerMismatchDiagnostic.propertyHasDefault( - propertyName: member.identifier.text + // 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) + context.diagnose(diagnostic) + } } } } diff --git a/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift b/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift index d158c5c2..c91c47ef 100644 --- a/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift +++ b/Tests/JSONSchemaMacroTests/InitializerDiagnosticsTests.swift @@ -31,6 +31,7 @@ struct InitializerDiagnosticsTests { .required() JSONProperty(key: "age") { JSONInteger() + .default(0) } .required() } @@ -54,7 +55,9 @@ struct InitializerDiagnosticsTests { ) } - @Test func multiplePropertiesWithDefaultValuesEmitsDiagnostic() { + @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 @@ -96,29 +99,6 @@ struct InitializerDiagnosticsTests { extension Config: Schemable { } """, - diagnostics: [ - DiagnosticSpec( - message: - "Property 'host' has a default value which will be excluded from the memberwise initializer", - line: 3, - column: 7, - severity: .warning - ), - DiagnosticSpec( - message: - "Property 'port' has a default value which will be excluded from the memberwise initializer", - line: 4, - column: 7, - severity: .warning - ), - DiagnosticSpec( - message: - "Property 'timeout' has a default value which will be excluded from the memberwise initializer", - line: 5, - column: 7, - severity: .warning - ), - ], macros: testMacros ) } @@ -361,8 +341,8 @@ struct InitializerDiagnosticsTests { 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: 2, - column: 8, + line: 1, + column: 1, severity: .error ) ], From af948cd92b5d5c329be8a426f327f36d70083c41 Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Tue, 4 Nov 2025 02:05:01 +0100 Subject: [PATCH 10/14] lint --- .../Schemable/SchemaOptionsGenerator.swift | 28 +++++++++---------- .../Schemable/SchemableMember.swift | 6 ++-- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Sources/JSONSchemaMacro/Schemable/SchemaOptionsGenerator.swift b/Sources/JSONSchemaMacro/Schemable/SchemaOptionsGenerator.swift index edafe108..23a3a804 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemaOptionsGenerator.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemaOptionsGenerator.swift @@ -44,9 +44,9 @@ enum SchemaOptionsGenerator { return applyClosureBasedOption(optionName, closure: closure, to: codeBlockItem) } else if let value = functionCall.arguments.first { return """ -\(codeBlockItem) -.\(raw: optionName)(\(value)) -""" + \(codeBlockItem) + .\(raw: optionName)(\(value)) + """ } return codeBlockItem @@ -65,24 +65,24 @@ enum SchemaOptionsGenerator { let bool = expr.as(BooleanLiteralExprSyntax.self) { return """ -\(codeBlockItem) -.additionalProperties(\(raw: bool.literal.text)) -""" + \(codeBlockItem) + .additionalProperties(\(raw: bool.literal.text)) + """ } // Intentionally fall through to "patternProperties" to handle shared logic. fallthrough case "patternProperties", "propertyNames": return """ -\(codeBlockItem) -.\(raw: optionName) { \(closure.statements) } -// Drop the parse information. Use custom builder if needed. -.map { $0.0 } -""" + \(codeBlockItem) + .\(raw: optionName) { \(closure.statements) } + // Drop the parse information. Use custom builder if needed. + .map { $0.0 } + """ default: return """ -\(codeBlockItem) -.\(raw: optionName) { \(closure.statements) } -""" + \(codeBlockItem) + .\(raw: optionName) { \(closure.statements) } + """ } } } diff --git a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift index 13e2b5c1..c5513f6d 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift @@ -89,9 +89,9 @@ struct SchemableMember { // In the future, JSONValue types should also be allowed to apply default value if let defaultValue { codeBlock = """ -\(codeBlock) -.default(\(defaultValue.trimmed)) -""" + \(codeBlock) + .default(\(defaultValue.trimmed)) + """ } case .schemable(_, let code): codeBlock = code case .notSupported: From c0dee46e35612476c60fb7166746357c3e8ba098 Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Tue, 4 Nov 2025 11:46:42 +0100 Subject: [PATCH 11/14] Remove unused isExcluded field from getAllStoredProperties The isExcluded field was never accessed after being set, and the logic was confusing due to the misleading name of shouldExcludeFromSchema. Since excludedProperties is computed by filtering, the field is redundant. --- .../Schemable/InitializerDiagnostics.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift b/Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift index 28bec842..47c7c9a6 100644 --- a/Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift +++ b/Sources/JSONSchemaMacro/Schemable/InitializerDiagnostics.swift @@ -46,21 +46,19 @@ struct InitializerDiagnostics { } /// Gets all stored properties including those marked with @ExcludeFromSchema - private func getAllStoredProperties() -> [(name: String, type: String, isExcluded: Bool)] { + 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, Bool)? 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 } - let isExcluded = !variableDecl.shouldExcludeFromSchema return ( name: identifier.text, - type: type.description.trimmingCharacters(in: .whitespaces), - isExcluded: isExcluded + type: type.description.trimmingCharacters(in: .whitespaces) ) } } @@ -130,7 +128,7 @@ struct InitializerDiagnostics { private func emitNoMatchingInitWarning( expectedParameters: [(name: String, type: String)], availableInits: [InitializerDeclSyntax], - excludedProperties: [(name: String, type: String, isExcluded: Bool)] + excludedProperties: [(name: String, type: String)] ) { let expectedSignature = expectedParameters.map { "\($0.name): \($0.type)" } .joined(separator: ", ") From 52e00fd5dbbc0b005e7c9abe550779e2dded33b9 Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Tue, 4 Nov 2025 11:49:24 +0100 Subject: [PATCH 12/14] Add missing SwiftSyntax dependencies to Package.swift Add explicit dependencies for: - JSONSchemaMacro: SwiftBasicFormat, SwiftDiagnostics, SwiftSyntaxBuilder - JSONSchemaMacroTests: SwiftParser, SwiftParserDiagnostics, SwiftBasicFormat, SwiftDiagnostics These are required by the diagnostic functionality added in the initializer and schema options diagnostics features. --- Package.swift | 7 +++++++ 1 file changed, 7 insertions(+) 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"), ] ), From dfaf8ce708f04d7fea8cde59f3ef09d0bae0a97b Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Wed, 5 Nov 2025 00:54:55 +0100 Subject: [PATCH 13/14] Restore isStatic extension lost in merge During the merge from main, the isStatic extension on VariableDeclSyntax and the static property filter in schemableMembers() were accidentally lost. This commit restores: - isStatic computed property on VariableDeclSyntax - Static property filtering in schemableMembers() - Missing comma in SchemableMember.generateSchema parameter list (from merge conflict) --- Sources/JSONSchemaMacro/Schemable/SchemableMember.swift | 2 +- .../JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift index 00d58396..e7d80e51 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift @@ -78,7 +78,7 @@ struct SchemableMember { func generateSchema( keyStrategy: ExprSyntax?, typeName: String, - codingKeys: [String: String]? = nil + codingKeys: [String: String]? = nil, context: (any MacroExpansionContext)? = nil ) -> CodeBlockItemSyntax? { var codeBlock: CodeBlockItemSyntax 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) From 936c2bf8ea0ee85723676d088f3ebc4adbb71c2f Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Wed, 5 Nov 2025 01:08:00 +0100 Subject: [PATCH 14/14] lint --- Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift index 2b372381..d1bae828 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift @@ -171,7 +171,12 @@ struct SchemaGenerator { } let statements = schemableMembers.compactMap { - $0.generateSchema(keyStrategy: keyStrategy, typeName: name.text, codingKeys: codingKeys, context: context) + $0.generateSchema( + keyStrategy: keyStrategy, + typeName: name.text, + codingKeys: codingKeys, + context: context + ) } var codeBlockItem: CodeBlockItemSyntax =