diff --git a/Package.swift b/Package.swift index 4373934..82407aa 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3 +// swift-tools-version:6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,7 +6,11 @@ import PackageDescription let package = Package( name: "jsonlogic", platforms: [ - .macOS(.v10_13), .iOS(.v11), .tvOS(.v9), .watchOS(.v4) + .macOS(.v15), + .iOS(.v18), + .tvOS(.v18), + .watchOS(.v11), + .visionOS(.v2) ], products: [ .library( @@ -20,7 +24,7 @@ let package = Package( targets: ["jsonlogic-cli"]), ], targets: [ - .target( + .executableTarget( name: "jsonlogic-cli", dependencies: ["jsonlogic"]), .target( @@ -31,10 +35,11 @@ let package = Package( dependencies: []), .testTarget( name: "jsonlogicTests", - dependencies: ["jsonlogic"]), + dependencies: ["jsonlogic"], + resources: [.copy("Resources/tests.json")]), .testTarget( name: "JSONTests", dependencies: ["JSON"]) ], - swiftLanguageVersions: [.v5, .v4_2, .v4] + swiftLanguageModes: [.v6] ) diff --git a/README.md b/README.md index 2c60427..e6a30d8 100644 --- a/README.md +++ b/README.md @@ -1,237 +1,240 @@ # jsonlogic-swift -[![CI Status](http://img.shields.io/travis/advantagefse/json-logic-swift.svg?style=flat)](https://travis-ci.org/advantagefse/json-logic-swift) -[![Version](https://img.shields.io/cocoapods/v/jsonlogic.svg?style=flat)](https://cocoapods.org/pods/jsonlogic) -[![Platform](https://img.shields.io/cocoapods/p/jsonlogic.svg?style=flat)](https://cocoapods.org/pods/jsonlogic) -[![codecov](https://codecov.io/gh/advantagefse/json-logic-swift/branch/master/graph/badge.svg)](https://codecov.io/gh/advantagefse/json-logic-swift) +A native Swift JsonLogic implementation. This parser accepts [JsonLogic](http://jsonlogic.com) +rules and executes them. -A native Swift JsonLogic implementation. This parser accepts [JsonLogic](http://jsonlogic.com) -rules and executes them. +JsonLogic is a way to write rules that involve computations in JSON format. These can be applied on JSON data with consistent results, allowing you to share rules between server and clients in a common format. -JsonLogic is a way to write rules that involve computations in JSON -format, these can be applied on JSON data with consistent results. So you can share between server and clients rules in a common format. Original JS JsonLogic implementation is developed by Jeremy Wadhams. +This fork has been updated for **Swift 6** and **iOS 18+** with full spec compliance. -## Instalation +## Features -#### Using CocoaPods +- Full [JSONLogic specification](http://jsonlogic.com/operations.html) compliance (278 official tests pass) +- Swift 6 language mode with strict concurrency safety +- `Sendable` conformance for all public types +- iOS 18+, macOS 15+, tvOS 18+, watchOS 11+, visionOS 2+ support +- Custom operator support -To use the pod in your project add in the Podfile: +## Installation - pod jsonlogic +### Using Swift Package Manager -To run the example project, just run: +Add the following to your `Package.swift`: - pod try jsonlogic +```swift +dependencies: [ + .package(url: "https://github.com/YOUR_USERNAME/json-logic-swift", from: "2.0.0") +] +``` + +And add `jsonlogic` to your target dependencies: -#### Using Swift Package Manager +```swift +.target( + name: "YourTarget", + dependencies: ["jsonlogic"] +) +``` -if you use Swift Package Manager add the following in dependencies: +## Requirements - dependencies: [ - .package( - url: "https://github.com/advantagefse/json-logic-swift", from: "1.0.0" - ) - ] +| Platform | Minimum Version | +|----------|-----------------| +| iOS | 18.0 | +| macOS | 15.0 | +| tvOS | 18.0 | +| watchOS | 11.0 | +| visionOS | 2.0 | +| Swift | 6.0 | ## Usage -You simply import the module and either call the applyRule global method: +Import the module and call the `applyRule` function: ```swift import jsonlogic -let rule = -""" +let rule = """ { "var" : "name" } """ -let data = -""" +let data = """ { "name" : "Jon" } """ -//Example parsing -let result: String? = try? applyRule(rule, to: data) - -print("result = \(String(describing: result))") +let result: String = try applyRule(rule, to: data) +print(result) // "Jon" ``` -The ```applyRule``` will parse the rule then apply it to the ```data``` and try to convert the -result to - the -inferred return -type, -if it fails an error will be thrown. +### Reusing Parsed Rules -If you need to apply the same rule to multiple data then it will be better to parse the rule once. -You can do this by initializing a ```JsonRule``` object with the rule and then calling -```applyRule```. +If you need to apply the same rule to multiple data objects, parse once and reuse: ```swift - -//Example parsing let jsonlogic = try JsonLogic(rule) -var result: Bool = jsonlogic.applyRule(to: data1) -result = jsonlogic.applyRule(to: data2) -//etc.. - +let result1: Bool = try jsonlogic.applyRule(to: data1) +let result2: Bool = try jsonlogic.applyRule(to: data2) ``` ## Examples -#### Simple -```Swift +### Simple Comparison + +```swift let rule = """ { "==" : [1, 1] } """ let result: Bool = try applyRule(rule) -//evaluates to true +// true ``` -This is a simple test, equivalent to `1 == 1`. A few things about the format: - - 1. The operator is always in the "key" position. There is only one key per JsonLogic rule. - 1. The values are typically an array. - 1. Each value can be a string, number, boolean, array (non-associative), or null +### Compound Logic -#### Compound -Here we're beginning to nest rules. - -```Swift +```swift let rule = """ - {"and" : [ - { ">" : [3,1] }, - { "<" : [1,3] } - ] } +{"and" : [ + { ">" : [3,1] }, + { "<" : [1,3] } +]} """ let result: Bool = try applyRule(rule) -//evaluates to true -``` - -In an infix language this could be written as: - -```Swift -( (3 > 1) && (1 < 3) ) +// true ``` -#### Data-Driven +### Data-Driven Rules -Obviously these rules aren't very interesting if they can only take static literal data. -Typically `jsonLogic` will be called with a rule object and a data object. You can use the `var` -operator to get attributes of the data object: +Access data using the `var` operator: -```Swift +```swift let rule = """ - { "var" : ["a"] } +{ "var" : "a" } """ let data = """ - { a : 1, b : 2 } +{ "a" : 1, "b" : 2 } """ let result: Int = try applyRule(rule, to: data) -//evaluates to 1 +// 1 ``` -If you like, we support to skip the array around values: +Access nested properties with dot notation: -```Swift +```swift let rule = """ - { "var" : "a" } +{ "var" : "user.profile.name" } """ let data = """ - { a : 1, b : 2 } +{ "user" : { "profile" : { "name" : "Alice" } } } """ -let result: Int = try applyRule(rule, to: data) -//evaluates to 1 +let result: String = try applyRule(rule, to: data) +// "Alice" ``` -You can also use the `var` operator to access an array by numeric index: +Access array elements by index: -```js -jsonLogic.apply( - {"var" : 1 }, - [ "apple", "banana", "carrot" ] -); +```swift +let rule = """ +{ "var" : 1 } +""" +let data = """ +["apple", "banana", "carrot"] +""" +let result: String = try applyRule(rule, to: data) // "banana" ``` -Here's a complex rule that mixes literals and data. The pie isn't ready to eat unless it's cooler than 110 degrees, *and* filled with apples. +### Complex Example -```Swift +```swift let rule = """ { "and" : [ {"<" : [ { "var" : "temp" }, 110 ]}, {"==" : [ { "var" : "pie.filling" }, "apple" ] } -] } +]} """ let data = """ - { "temp" : 100, "pie" : { "filling" : "apple" } } +{ "temp" : 100, "pie" : { "filling" : "apple" } } """ let result: Bool = try applyRule(rule, to: data) -//evaluates to true +// true ``` -### Custom operators +### Custom Operators -You can register a custom operator +Register custom operators: -```Swift +```swift import jsonlogic import JSON -// the key is the operator and the value is a closure that takes as argument -// a JSON and returns a JSON -let customRules = - ["numberOfElementsInArray": { (json: JSON?) -> JSON in +let customRules: [String: (JSON?) -> JSON] = [ + "numberOfElementsInArray": { json in switch json { case let .Array(array): return JSON(array.count) default: return JSON(0) } - }] - + } +] + let rule = """ - { "numberOfElementsInArray" : [1, 2, 3] } +{ "numberOfElementsInArray" : [1, 2, 3] } """ - -// The value is 3 + let value: Int = try JsonLogic(rule, customOperators: customRules).applyRule() +// 3 ``` -### Other operators +## Supported Operators -For a complete list of the supported operators and their usages see [jsonlogic operators](http://jsonlogic.com/operations.html). +All [standard JSONLogic operators](http://jsonlogic.com/operations.html) are supported: -### Command Line Interface +- **Logic**: `if`/`?:`, `==`, `===`, `!=`, `!==`, `!`, `!!`, `or`, `and` +- **Numeric**: `>`, `>=`, `<`, `<=`, `max`, `min`, `+`, `-`, `*`, `/`, `%` +- **Array**: `map`, `reduce`, `filter`, `all`, `some`, `none`, `merge`, `in` +- **String**: `cat`, `substr`, `in` +- **Data Access**: `var`, `missing`, `missing_some` +- **Utility**: `log` -Comming soon... +## Thread Safety -## Contributing +All public types conform to `Sendable` and are safe for use across actor boundaries: -Making changes are welcome. -If you find a bug please submit a unit test that reproduces it, before submitting the fix. +```swift +actor RuleEngine { + private let rule: JsonLogic -Because the project was created and build using the Swift PM there is no Xcode project file -committed in the repo. If you need one you can generated by running ```genenate-xcodeproj.sh ``` -in the terminal: + init(rule: String) throws { + self.rule = try JsonLogic(rule) + } -``` -$ . generate-xcodeproj.sh + func evaluate(data: String) throws -> Bool { + try rule.applyRule(to: data) + } +} ``` -## Requirements +## Error Handling +```swift +public enum JSONLogicError: Error { + case canNotParseJSONData(String) + case canNotParseJSONRule(String) + case canNotConvertResultToType(Any.Type) +} +``` -| iOS | tvOS | watchOS | macOS | -| :------: |:----------:|:----------:|:----------:| -| >=9.0 | >=10.0 | >=2.0 | >=10.12 | +## Contributing +Contributions are welcome! Please ensure all tests pass before submitting PRs: -## Author +```bash +swift test +``` -Christos Koninis, c.koninis@afse.eu +The test suite includes 278 official JSONLogic specification tests plus additional unit tests. ## License diff --git a/Sources/JSON/JSON.swift b/Sources/JSON/JSON.swift index 82e4e68..d60eb58 100644 --- a/Sources/JSON/JSON.swift +++ b/Sources/JSON/JSON.swift @@ -8,7 +8,7 @@ import Foundation -public enum JSON: Equatable { +public enum JSON: Equatable, Sendable { case Null case Array([JSON]) case Dictionary([String: JSON]) @@ -18,7 +18,7 @@ public enum JSON: Equatable { case Bool(Bool) case Error(JSON2Error) - public enum ContentType { + public enum ContentType: Sendable { case error, null, bool, number, string, array, object } @@ -41,13 +41,13 @@ public enum JSON: Equatable { } } - public enum JSON2Error: Error, Equatable, Hashable { + public enum JSON2Error: Error, Equatable, Hashable, Sendable { case failedToParse case notJSONValue case indexOutOfRange(Int) case keyNotFound(String) case notSubscriptableType(ContentType) - case NSError(NSError) + case nsError(String) // Changed from NSError to String for Sendable compliance } public init() { @@ -84,7 +84,7 @@ public enum JSON: Equatable { default: self = .Error(.notJSONValue) } - case Optional.none, nil, is NSNull: + case is NSNull: self = .Null case let bool as Bool: self = .Bool(bool) @@ -93,7 +93,7 @@ public enum JSON: Equatable { case let double as Swift.Double: self = .Double(double) default: - self = .Error(.NSError(NSError(domain: "Can't convert value \(json) to JSON", code: 1))) + self = .Error(.nsError("Can't convert value \(json) to JSON")) } } //swiftlint:enable syntactic_sugar @@ -102,7 +102,7 @@ public enum JSON: Equatable { do { self.init(try JSONSerialization.jsonObject(with: data, options: [.allowFragments])) } catch let error as NSError { - self = .Error(.NSError(error)) + self = .Error(.nsError(error.localizedDescription)) } catch { self = .Error(.failedToParse) } @@ -507,8 +507,8 @@ extension JSON { return !string.isEmpty case let .Array(array): return !array.isEmpty - case .Dictionary(dictionary): - return !dictionary!.isEmpty + case let .Dictionary(dictionary): + return !dictionary.isEmpty default: return false } diff --git a/Sources/jsonlogic/JsonLogic.swift b/Sources/jsonlogic/JsonLogic.swift index 28b40d2..8a05093 100644 --- a/Sources/jsonlogic/JsonLogic.swift +++ b/Sources/jsonlogic/JsonLogic.swift @@ -160,19 +160,18 @@ extension JSON { throw JSONLogicError.canNotParseJSONData("\(self)") case .Null: return Optional.none - case .Bool: - return self.bool - case .Int: - return Swift.Int(self.int!) - case .Double: - return self.double - case .String: - return self.string - case let JSON.Array(array): + case let .Bool(value): + return value + case let .Int(value): + return Swift.Int(value) + case let .Double(value): + return value + case let .String(value): + return value + case let .Array(array): return try array.map { try $0.convertToSwiftTypes() } - case .Dictionary: - let o = self.dictionary! - return try o.mapValues { + case let .Dictionary(dictionary): + return try dictionary.mapValues { try $0.convertToSwiftTypes() } } diff --git a/Sources/jsonlogic/Parser.swift b/Sources/jsonlogic/Parser.swift index cb76ed3..d339849 100644 --- a/Sources/jsonlogic/Parser.swift +++ b/Sources/jsonlogic/Parser.swift @@ -148,9 +148,8 @@ struct Comparison: Expression { case JSON.String(_) = array[1] { return JSON(booleanLiteral: operation(array[0], array[1])) } - let lala = operation(array[0], array[1]) - let papa = JSON.Bool(lala) - return papa + let comparisonResult = operation(array[0], array[1]) + return JSON.Bool(comparisonResult) case let .Array(array) where array.count == 3: return JSON.Bool(operation(array[0], array[1]) && operation(array[1], array[2])) @@ -189,9 +188,9 @@ struct LogicalAndOr: Expression { func evalWithData(_ data: JSON?) throws -> JSON { for expression in arg.expressions { - let data = try expression.evalWithData(data) - if data.truthy() == !isAnd { - return data + let result = try expression.evalWithData(data) + if result.truthy() == !isAnd { + return result } } @@ -203,9 +202,9 @@ struct DoubleNegation: Expression { let arg: Expression func evalWithData(_ data: JSON?) throws -> JSON { - let data = try arg.evalWithData(data) - guard case let JSON.Array(array) = data else { - return JSON.Bool(data.truthy()) + let result = try arg.evalWithData(data) + guard case let JSON.Array(array) = result else { + return JSON.Bool(result.truthy()) } if let firstItem = array.first { return JSON.Bool(firstItem.truthy()) @@ -360,45 +359,94 @@ struct Var: Expression { return defaultArgument } - let variablePath = try evaluateVarPathFromData(data) - if let variablePathParts = variablePath?.split(separator: ".").map({String($0)}) { - var partialResult: JSON? = data - for key in variablePathParts { - if partialResult?.type == .array { - if let index = Int(key), let maxElement = partialResult?.array?.count, index < maxElement, index >= 0 { - partialResult = partialResult?[index] + let variablePathResult = try evaluateVarPathFromData(data) + + // Handle special cases: null, empty string, empty array all return the entire data + switch variablePathResult { + case .returnData: + return data + case .notFound: + return defaultArgument + case .path(let variablePath): + // Empty path returns the entire data + if variablePath.isEmpty { + return data + } + + let variablePathParts = variablePath.split(separator: ".").map { String($0) } + var partialResult: JSON? = data + + for key in variablePathParts { + if partialResult?.type == .array { + if let index = Int(key), + let maxElement = partialResult?.array?.count, + index < maxElement, + index >= 0 { + partialResult = partialResult?[index] + } else { + partialResult = partialResult?[key] + } } else { - partialResult = partialResult?[key] + partialResult = partialResult?[key] } - } else { - partialResult = partialResult?[key] - } - } + } - guard let partialResult = partialResult else { - return defaultArgument - } + guard let partialResult = partialResult else { + return defaultArgument + } - if case JSON.Error(_) = partialResult { - return defaultArgument - } + if case JSON.Error(_) = partialResult { + return defaultArgument + } - return partialResult - } + return partialResult + } + } - return JSON.Null + private enum VarPathResult { + case returnData // null, empty array - return entire data + case path(String) // string path or integer index + case notFound // couldn't determine path } - func evaluateVarPathFromData(_ data: JSON) throws -> String? { + private func evaluateVarPathFromData(_ data: JSON) throws -> VarPathResult { let variablePathAsJSON = try self.expression.evalWithData(data) switch variablePathAsJSON { case let .String(string): - return string + // Empty string means return entire data + if string.isEmpty { + return .returnData + } + return .path(string) + case let .Int(index): + // Integer index for array access + return .path(String(index)) case let .Array(array): - return array.first?.string + // Empty array means return entire data + if array.isEmpty { + return .returnData + } + // First element should be the path + if let first = array.first { + switch first { + case let .String(string): + if string.isEmpty { + return .returnData + } + return .path(string) + case let .Int(index): + return .path(String(index)) + default: + return .notFound + } + } + return .notFound + case .Null: + // null means return entire data + return .returnData default: - return nil + return .notFound } } } @@ -492,7 +540,7 @@ struct ArrayMap: Expression { array.expressions.count >= 2, case let JSON.Array(dataArray) = try array.expressions[0].evalWithData(data) else { - return JSON(string: "[]")! + return JSON.Array([]) } let mapOperation = array.expressions[1] @@ -665,8 +713,11 @@ class Parser { for (key, value) in object { arrayOfExpressions.append(try parseExpressionWithKeyword(key, value: value)) } - //use only the first for now, we should warn or throw error here if array count > 1 - return arrayOfExpressions.first! + // Use only the first expression; empty dictionaries return null + guard let firstExpression = arrayOfExpressions.first else { + return SingleValueExpression(json: .Null) + } + return firstExpression } } diff --git a/Tests/JSONTests/JSONTests.swift b/Tests/JSONTests/JSONTests.swift index ea689ea..7b68b3d 100644 --- a/Tests/JSONTests/JSONTests.swift +++ b/Tests/JSONTests/JSONTests.swift @@ -250,8 +250,8 @@ class JSONTests: XCTestCase { jsonInts[8] = 0 - //It should pad the index with nil (JSON.Nil) - XCTAssertEqual(jsonInts, JSON([3, 2, 1, nil, nil, nil, nil, nil, 0])) + // It should pad the index with JSON.Null values + XCTAssertEqual(jsonInts, JSON.Array([3, 2, 1, .Null, .Null, .Null, .Null, .Null, 0])) } func testJSONSubscriptGet_GivenJSONDictionary() throws { @@ -357,8 +357,8 @@ class JSONTests: XCTestCase { return nil }() - let parseError = NSError(domain: "Can't convert value \(AClass()) to JSON", code: 1) - XCTAssertEqual(error, .NSError(parseError)) + // Error now stores the message as a String for Sendable compliance + XCTAssertEqual(error, .nsError("Can't convert value \(AClass()) to JSON")) } } diff --git a/Tests/jsonlogicTests/Resources/tests.json b/Tests/jsonlogicTests/Resources/tests.json new file mode 100644 index 0000000..7ff2883 --- /dev/null +++ b/Tests/jsonlogicTests/Resources/tests.json @@ -0,0 +1,532 @@ +[ + "# Non-rules get passed through", + [ true, {}, true ], + [ false, {}, false ], + [ 17, {}, 17 ], + [ 3.14, {}, 3.14 ], + [ "apple", {}, "apple" ], + [ null, {}, null ], + [ ["a","b"], {}, ["a","b"] ], + + "# Single operator tests", + [ {"==":[1,1]}, {}, true ], + [ {"==":[1,"1"]}, {}, true ], + [ {"==":[1,2]}, {}, false ], + [ {"===":[1,1]}, {}, true ], + [ {"===":[1,"1"]}, {}, false ], + [ {"===":[1,2]}, {}, false ], + [ {"!=":[1,2]}, {}, true ], + [ {"!=":[1,1]}, {}, false ], + [ {"!=":[1,"1"]}, {}, false ], + [ {"!==":[1,2]}, {}, true ], + [ {"!==":[1,1]}, {}, false ], + [ {"!==":[1,"1"]}, {}, true ], + [ {">":[2,1]}, {}, true ], + [ {">":[1,1]}, {}, false ], + [ {">":[1,2]}, {}, false ], + [ {">":["2",1]}, {}, true ], + [ {">=":[2,1]}, {}, true ], + [ {">=":[1,1]}, {}, true ], + [ {">=":[1,2]}, {}, false ], + [ {">=":["2",1]}, {}, true ], + [ {"<":[2,1]}, {}, false ], + [ {"<":[1,1]}, {}, false ], + [ {"<":[1,2]}, {}, true ], + [ {"<":["1",2]}, {}, true ], + [ {"<":[1,2,3]}, {}, true ], + [ {"<":[1,1,3]}, {}, false ], + [ {"<":[1,4,3]}, {}, false ], + [ {"<=":[2,1]}, {}, false ], + [ {"<=":[1,1]}, {}, true ], + [ {"<=":[1,2]}, {}, true ], + [ {"<=":["1",2]}, {}, true ], + [ {"<=":[1,2,3]}, {}, true ], + [ {"<=":[1,4,3]}, {}, false ], + [ {"!":[false]}, {}, true ], + [ {"!":false}, {}, true ], + [ {"!":[true]}, {}, false ], + [ {"!":true}, {}, false ], + [ {"!":0}, {}, true ], + [ {"!":1}, {}, false ], + [ {"or":[true,true]}, {}, true ], + [ {"or":[false,true]}, {}, true ], + [ {"or":[true,false]}, {}, true ], + [ {"or":[false,false]}, {}, false ], + [ {"or":[false,false,true]}, {}, true ], + [ {"or":[false,false,false]}, {}, false ], + [ {"or":[false]}, {}, false ], + [ {"or":[true]}, {}, true ], + [ {"or":[1,3]}, {}, 1 ], + [ {"or":[3,false]}, {}, 3 ], + [ {"or":[false,3]}, {}, 3 ], + [ {"and":[true,true]}, {}, true ], + [ {"and":[false,true]}, {}, false ], + [ {"and":[true,false]}, {}, false ], + [ {"and":[false,false]}, {}, false ], + [ {"and":[true,true,true]}, {}, true ], + [ {"and":[true,true,false]}, {}, false ], + [ {"and":[false]}, {}, false ], + [ {"and":[true]}, {}, true ], + [ {"and":[1,3]}, {}, 3 ], + [ {"and":[3,false]}, {}, false ], + [ {"and":[false,3]}, {}, false ], + [ {"?:":[true,1,2]}, {}, 1 ], + [ {"?:":[false,1,2]}, {}, 2 ], + [ {"in":["Bart",["Bart","Homer","Lisa","Marge","Maggie"]]}, {}, true ], + [ {"in":["Milhouse",["Bart","Homer","Lisa","Marge","Maggie"]]}, {}, false ], + [ {"in":["Spring","Springfield"]}, {}, true ], + [ {"in":["i","team"]}, {}, false ], + [ {"cat":"ice"}, {}, "ice" ], + [ {"cat":["ice"]}, {}, "ice" ], + [ {"cat":["ice","cream"]}, {}, "icecream" ], + [ {"cat":[1,2]}, {}, "12" ], + [ {"cat":["Robocop",2]}, {}, "Robocop2" ], + [ {"cat":["we all scream for ","ice","cream"]}, {}, "we all scream for icecream" ], + [ {"%":[1,2]}, {}, 1 ], + [ {"%":[2,2]}, {}, 0 ], + [ {"%":[3,2]}, {}, 1 ], + [ {"max":[1,2,3]}, {}, 3 ], + [ {"max":[1,3,3]}, {}, 3 ], + [ {"max":[3,2,1]}, {}, 3 ], + [ {"max":[1]}, {}, 1 ], + [ {"min":[1,2,3]}, {}, 1 ], + [ {"min":[1,1,3]}, {}, 1 ], + [ {"min":[3,2,1]}, {}, 1 ], + [ {"min":[1]}, {}, 1 ], + + [ {"+":[1,2]}, {}, 3 ], + [ {"+":[2,2,2]}, {}, 6 ], + [ {"+":[1]}, {}, 1 ], + [ {"+":["1",1]}, {}, 2 ], + [ {"*":[3,2]}, {}, 6 ], + [ {"*":[2,2,2]}, {}, 8 ], + [ {"*":[1]}, {}, 1 ], + [ {"*":["1",1]}, {}, 1 ], + [ {"-":[2,3]}, {}, -1 ], + [ {"-":[3,2]}, {}, 1 ], + [ {"-":[3]}, {}, -3 ], + [ {"-":["1",1]}, {}, 0 ], + [ {"/":[4,2]}, {}, 2 ], + [ {"/":[2,4]}, {}, 0.5 ], + [ {"/":["1",1]}, {}, 1 ], + + "Substring", + [{"substr":["jsonlogic", 4]}, null, "logic"], + [{"substr":["jsonlogic", -5]}, null, "logic"], + [{"substr":["jsonlogic", 0, 1]}, null, "j"], + [{"substr":["jsonlogic", -1, 1]}, null, "c"], + [{"substr":["jsonlogic", 4, 5]}, null, "logic"], + [{"substr":["jsonlogic", -5, 5]}, null, "logic"], + [{"substr":["jsonlogic", -5, -2]}, null, "log"], + [{"substr":["jsonlogic", 1, -5]}, null, "son"], + + "Merge arrays", + [{"merge":[]}, null, []], + [{"merge":[[1]]}, null, [1]], + [{"merge":[[1],[]]}, null, [1]], + [{"merge":[[1], [2]]}, null, [1,2]], + [{"merge":[[1], [2], [3]]}, null, [1,2,3]], + [{"merge":[[1, 2], [3]]}, null, [1,2,3]], + [{"merge":[[1], [2, 3]]}, null, [1,2,3]], + "Given non-array arguments, merge converts them to arrays", + [{"merge":1}, null, [1]], + [{"merge":[1,2]}, null, [1,2]], + [{"merge":[1,[2]]}, null, [1,2]], + + "Too few args", + [{"if":[]}, null, null], + [{"if":[true]}, null, true], + [{"if":[false]}, null, false], + [{"if":["apple"]}, null, "apple"], + + "Simple if/then/else cases", + [{"if":[true, "apple"]}, null, "apple"], + [{"if":[false, "apple"]}, null, null], + [{"if":[true, "apple", "banana"]}, null, "apple"], + [{"if":[false, "apple", "banana"]}, null, "banana"], + + "Empty arrays are falsey", + [{"if":[ [], "apple", "banana"]}, null, "banana"], + [{"if":[ [1], "apple", "banana"]}, null, "apple"], + [{"if":[ [1,2,3,4], "apple", "banana"]}, null, "apple"], + + "Empty strings are falsey, all other strings are truthy", + [{"if":[ "", "apple", "banana"]}, null, "banana"], + [{"if":[ "zucchini", "apple", "banana"]}, null, "apple"], + [{"if":[ "0", "apple", "banana"]}, null, "apple"], + + "You can cast a string to numeric with a unary + ", + [{"===":[0,"0"]}, null, false], + [{"===":[0,{"+":"0"}]}, null, true], + [{"if":[ {"+":"0"}, "apple", "banana"]}, null, "banana"], + [{"if":[ {"+":"1"}, "apple", "banana"]}, null, "apple"], + + "Zero is falsy, all other numbers are truthy", + [{"if":[ 0, "apple", "banana"]}, null, "banana"], + [{"if":[ 1, "apple", "banana"]}, null, "apple"], + [{"if":[ 3.1416, "apple", "banana"]}, null, "apple"], + [{"if":[ -1, "apple", "banana"]}, null, "apple"], + + "Truthy and falsy definitions matter in Boolean operations", + [{"!" : [ [] ]}, {}, true], + [{"!!" : [ [] ]}, {}, false], + [{"and" : [ [], true ]}, {}, [] ], + [{"or" : [ [], true ]}, {}, true ], + + [{"!" : [ 0 ]}, {}, true], + [{"!!" : [ 0 ]}, {}, false], + [{"and" : [ 0, true ]}, {}, 0 ], + [{"or" : [ 0, true ]}, {}, true ], + + [{"!" : [ "" ]}, {}, true], + [{"!!" : [ "" ]}, {}, false], + [{"and" : [ "", true ]}, {}, "" ], + [{"or" : [ "", true ]}, {}, true ], + + [{"!" : [ "0" ]}, {}, false], + [{"!!" : [ "0" ]}, {}, true], + [{"and" : [ "0", true ]}, {}, true ], + [{"or" : [ "0", true ]}, {}, "0" ], + + "If the conditional is logic, it gets evaluated", + [{"if":[ {">":[2,1]}, "apple", "banana"]}, null, "apple"], + [{"if":[ {">":[1,2]}, "apple", "banana"]}, null, "banana"], + + "If the consequents are logic, they get evaluated", + [{"if":[ true, {"cat":["ap","ple"]}, {"cat":["ba","na","na"]} ]}, null, "apple"], + [{"if":[ false, {"cat":["ap","ple"]}, {"cat":["ba","na","na"]} ]}, null, "banana"], + + "If/then/elseif/then cases", + [{"if":[true, "apple", true, "banana"]}, null, "apple"], + [{"if":[true, "apple", false, "banana"]}, null, "apple"], + [{"if":[false, "apple", true, "banana"]}, null, "banana"], + [{"if":[false, "apple", false, "banana"]}, null, null], + + [{"if":[true, "apple", true, "banana", "carrot"]}, null, "apple"], + [{"if":[true, "apple", false, "banana", "carrot"]}, null, "apple"], + [{"if":[false, "apple", true, "banana", "carrot"]}, null, "banana"], + [{"if":[false, "apple", false, "banana", "carrot"]}, null, "carrot"], + + [{"if":[false, "apple", false, "banana", false, "carrot"]}, null, null], + [{"if":[false, "apple", false, "banana", false, "carrot", "date"]}, null, "date"], + [{"if":[false, "apple", false, "banana", true, "carrot", "date"]}, null, "carrot"], + [{"if":[false, "apple", true, "banana", false, "carrot", "date"]}, null, "banana"], + [{"if":[false, "apple", true, "banana", true, "carrot", "date"]}, null, "banana"], + [{"if":[true, "apple", false, "banana", false, "carrot", "date"]}, null, "apple"], + [{"if":[true, "apple", false, "banana", true, "carrot", "date"]}, null, "apple"], + [{"if":[true, "apple", true, "banana", false, "carrot", "date"]}, null, "apple"], + [{"if":[true, "apple", true, "banana", true, "carrot", "date"]}, null, "apple"], + + "Arrays with logic", + [[1, {"var": "x"}, 3], {"x": 2}, [1, 2, 3]], + [{"if": [{"var": "x"}, [{"var": "y"}], 99]}, {"x": true, "y": 42}, [42]], + + "# Compound Tests", + [ {"and":[{">":[3,1]},true]}, {}, true ], + [ {"and":[{">":[3,1]},false]}, {}, false ], + [ {"and":[{">":[3,1]},{"!":true}]}, {}, false ], + [ {"and":[{">":[3,1]},{"<":[1,3]}]}, {}, true ], + [ {"?:":[{">":[3,1]},"visible","hidden"]}, {}, "visible" ], + + "# Data-Driven", + [ {"var":["a"]},{"a":1},1 ], + [ {"var":["b"]},{"a":1},null ], + [ {"var":["a"]},null,null ], + [ {"var":"a"},{"a":1},1 ], + [ {"var":"b"},{"a":1},null ], + [ {"var":"a"},null,null ], + [ {"var":["a", 1]},null,1 ], + [ {"var":["b", 2]},{"a":1},2 ], + [ {"var":"a.b"},{"a":{"b":"c"}},"c" ], + [ {"var":"a.q"},{"a":{"b":"c"}},null ], + [ {"var":["a.q", 9]},{"a":{"b":"c"}},9 ], + [ {"var":1}, ["apple","banana"], "banana" ], + [ {"var":"1"}, ["apple","banana"], "banana" ], + [ {"var":"1.1"}, ["apple",["banana","beer"]], "beer" ], + [ {"and":[{"<":[{"var":"temp"},110]},{"==":[{"var":"pie.filling"},"apple"]}]},{"temp":100,"pie":{"filling":"apple"}},true ], + [ {"var":[{"?:":[{"<":[{"var":"temp"},110]},"pie.filling","pie.eta"]}]},{"temp":100,"pie":{"filling":"apple","eta":"60s"}},"apple" ], + [ {"in":[{"var":"filling"},["apple","cherry"]]},{"filling":"apple"},true ], + [ {"var":"a.b.c"}, null, null ], + [ {"var":"a.b.c"}, {"a":null}, null ], + [ {"var":"a.b.c"}, {"a":{"b":null}}, null ], + [ {"var":""}, 1, 1 ], + [ {"var":null}, 1, 1 ], + [ {"var":[]}, 1, 1 ], + + "Missing", + [{"missing":[]}, null, []], + [{"missing":["a"]}, null, ["a"]], + [{"missing":"a"}, null, ["a"]], + [{"missing":"a"}, {"a":"apple"}, []], + [{"missing":["a"]}, {"a":"apple"}, []], + [{"missing":["a","b"]}, {"a":"apple"}, ["b"]], + [{"missing":["a","b"]}, {"b":"banana"}, ["a"]], + [{"missing":["a","b"]}, {"a":"apple", "b":"banana"}, []], + [{"missing":["a","b"]}, {}, ["a","b"]], + [{"missing":["a","b"]}, null, ["a","b"]], + + [{"missing":["a.b"]}, null, ["a.b"]], + [{"missing":["a.b"]}, {"a":"apple"}, ["a.b"]], + [{"missing":["a.b"]}, {"a":{"c":"apple cake"}}, ["a.b"]], + [{"missing":["a.b"]}, {"a":{"b":"apple brownie"}}, []], + [{"missing":["a.b", "a.c"]}, {"a":{"b":"apple brownie"}}, ["a.c"]], + + + "Missing some", + [{"missing_some":[1, ["a", "b"]]}, {"a":"apple"}, [] ], + [{"missing_some":[1, ["a", "b"]]}, {"b":"banana"}, [] ], + [{"missing_some":[1, ["a", "b"]]}, {"a":"apple", "b":"banana"}, [] ], + [{"missing_some":[1, ["a", "b"]]}, {"c":"carrot"}, ["a", "b"]], + + [{"missing_some":[2, ["a", "b", "c"]]}, {"a":"apple", "b":"banana"}, [] ], + [{"missing_some":[2, ["a", "b", "c"]]}, {"a":"apple", "c":"carrot"}, [] ], + [{"missing_some":[2, ["a", "b", "c"]]}, {"a":"apple", "b":"banana", "c":"carrot"}, [] ], + [{"missing_some":[2, ["a", "b", "c"]]}, {"a":"apple", "d":"durian"}, ["b", "c"] ], + [{"missing_some":[2, ["a", "b", "c"]]}, {"d":"durian", "e":"eggplant"}, ["a", "b", "c"] ], + + + "Missing and If are friends, because empty arrays are falsey in JsonLogic", + [{"if":[ {"missing":"a"}, "missed it", "found it" ]}, {"a":"apple"}, "found it"], + [{"if":[ {"missing":"a"}, "missed it", "found it" ]}, {"b":"banana"}, "missed it"], + + "Missing, Merge, and If are friends. VIN is always required, APR is only required if financing is true.", + [ + {"missing":{"merge":[ "vin", {"if": [{"var":"financing"}, ["apr"], [] ]} ]} }, + {"financing":true}, + ["vin","apr"] + ], + + [ + {"missing":{"merge":[ "vin", {"if": [{"var":"financing"}, ["apr"], [] ]} ]} }, + {"financing":false}, + ["vin"] + ], + + "Filter, map, all, none, and some", + [ + {"filter":[{"var":"integers"}, true]}, + {"integers":[1,2,3]}, + [1,2,3] + ], + [ + {"filter":[{"var":"integers"}, false]}, + {"integers":[1,2,3]}, + [] + ], + [ + {"filter":[{"var":"integers"}, {">=":[{"var":""},2]}]}, + {"integers":[1,2,3]}, + [2,3] + ], + [ + {"filter":[{"var":"integers"}, {"%":[{"var":""},2]}]}, + {"integers":[1,2,3]}, + [1,3] + ], + + [ + {"map":[{"var":"integers"}, {"*":[{"var":""},2]}]}, + {"integers":[1,2,3]}, + [2,4,6] + ], + [ + {"map":[{"var":"integers"}, {"*":[{"var":""},2]}]}, + null, + [] + ], + [ + {"map":[{"var":"desserts"}, {"var":"qty"}]}, + {"desserts":[ + {"name":"apple","qty":1}, + {"name":"brownie","qty":2}, + {"name":"cupcake","qty":3} + ]}, + [1,2,3] + ], + + [ + {"reduce":[ + {"var":"integers"}, + {"+":[{"var":"current"}, {"var":"accumulator"}]}, + 0 + ]}, + {"integers":[1,2,3,4]}, + 10 + ], + [ + {"reduce":[ + {"var":"integers"}, + {"+":[{"var":"current"}, {"var":"accumulator"}]}, + {"var": "start_with"} + ]}, + {"integers":[1,2,3,4], "start_with": 59}, + 69 + ], + [ + {"reduce":[ + {"var":"integers"}, + {"+":[{"var":"current"}, {"var":"accumulator"}]}, + 0 + ]}, + null, + 0 + ], + [ + {"reduce":[ + {"var":"integers"}, + {"*":[{"var":"current"}, {"var":"accumulator"}]}, + 1 + ]}, + {"integers":[1,2,3,4]}, + 24 + ], + [ + {"reduce":[ + {"var":"integers"}, + {"*":[{"var":"current"}, {"var":"accumulator"}]}, + 0 + ]}, + {"integers":[1,2,3,4]}, + 0 + ], + [ + {"reduce": [ + {"var":"desserts"}, + {"+":[ {"var":"accumulator"}, {"var":"current.qty"}]}, + 0 + ]}, + {"desserts":[ + {"name":"apple","qty":1}, + {"name":"brownie","qty":2}, + {"name":"cupcake","qty":3} + ]}, + 6 + ], + + + [ + {"all":[{"var":"integers"}, {">=":[{"var":""}, 1]}]}, + {"integers":[1,2,3]}, + true + ], + [ + {"all":[{"var":"integers"}, {"==":[{"var":""}, 1]}]}, + {"integers":[1,2,3]}, + false + ], + [ + {"all":[{"var":"integers"}, {"<":[{"var":""}, 1]}]}, + {"integers":[1,2,3]}, + false + ], + [ + {"all":[{"var":"integers"}, {"<":[{"var":""}, 1]}]}, + {"integers":[]}, + false + ], + [ + {"all":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]}, + {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]}, + true + ], + [ + {"all":[ {"var":"items"}, {">":[{"var":"qty"}, 1]}]}, + {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]}, + false + ], + [ + {"all":[ {"var":"items"}, {"<":[{"var":"qty"}, 1]}]}, + {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]}, + false + ], + [ + {"all":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]}, + {"items":[]}, + false + ], + + + [ + {"none":[{"var":"integers"}, {">=":[{"var":""}, 1]}]}, + {"integers":[1,2,3]}, + false + ], + [ + {"none":[{"var":"integers"}, {"==":[{"var":""}, 1]}]}, + {"integers":[1,2,3]}, + false + ], + [ + {"none":[{"var":"integers"}, {"<":[{"var":""}, 1]}]}, + {"integers":[1,2,3]}, + true + ], + [ + {"none":[{"var":"integers"}, {"<":[{"var":""}, 1]}]}, + {"integers":[]}, + true + ], + [ + {"none":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]}, + {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]}, + false + ], + [ + {"none":[ {"var":"items"}, {">":[{"var":"qty"}, 1]}]}, + {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]}, + false + ], + [ + {"none":[ {"var":"items"}, {"<":[{"var":"qty"}, 1]}]}, + {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]}, + true + ], + [ + {"none":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]}, + {"items":[]}, + true + ], + + [ + {"some":[{"var":"integers"}, {">=":[{"var":""}, 1]}]}, + {"integers":[1,2,3]}, + true + ], + [ + {"some":[{"var":"integers"}, {"==":[{"var":""}, 1]}]}, + {"integers":[1,2,3]}, + true + ], + [ + {"some":[{"var":"integers"}, {"<":[{"var":""}, 1]}]}, + {"integers":[1,2,3]}, + false + ], + [ + {"some":[{"var":"integers"}, {"<":[{"var":""}, 1]}]}, + {"integers":[]}, + false + ], + [ + {"some":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]}, + {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]}, + true + ], + [ + {"some":[ {"var":"items"}, {">":[{"var":"qty"}, 1]}]}, + {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]}, + true + ], + [ + {"some":[ {"var":"items"}, {"<":[{"var":"qty"}, 1]}]}, + {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]}, + false + ], + [ + {"some":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]}, + {"items":[]}, + false + ], + + "EOF" +] diff --git a/Tests/jsonlogicTests/SpecComplianceTests.swift b/Tests/jsonlogicTests/SpecComplianceTests.swift new file mode 100644 index 0000000..cd30a15 --- /dev/null +++ b/Tests/jsonlogicTests/SpecComplianceTests.swift @@ -0,0 +1,394 @@ +// +// SpecComplianceTests.swift +// jsonlogic +// +// Official JSONLogic specification compliance tests from https://jsonlogic.com/tests.json +// These tests ensure cross-platform compatibility with JS and Java implementations. +// + +import XCTest +import JSON +@testable import jsonlogic + +final class SpecComplianceTests: XCTestCase { + + private var specTests: [[Any]] = [] + + override func setUp() { + super.setUp() + loadSpecTests() + } + + private func loadSpecTests() { + guard let url = Bundle.module.url(forResource: "tests", withExtension: "json") else { + XCTFail("Could not find tests.json resource") + return + } + + do { + let data = try Data(contentsOf: url) + guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [Any] else { + XCTFail("Could not parse tests.json as array") + return + } + specTests = json.compactMap { item -> [Any]? in + // Skip comment strings (like "# Single operator tests") + guard let testCase = item as? [Any], testCase.count == 3 else { + return nil + } + return testCase + } + } catch { + XCTFail("Failed to load tests.json: \(error)") + } + } + + func testSpecCompliance() throws { + var failures: [(rule: String, data: String, expected: String, actual: String, error: String?)] = [] + var passCount = 0 + + for testCase in specTests { + guard testCase.count == 3 else { continue } + + let rule = testCase[0] + let data = testCase[1] + let expected = testCase[2] + + let ruleJSON: String + let dataJSON: String? + + do { + let ruleData = try JSONSerialization.data(withJSONObject: rule, options: [.fragmentsAllowed]) + ruleJSON = String(data: ruleData, encoding: .utf8) ?? "" + + if data is NSNull { + dataJSON = nil + } else { + let dataData = try JSONSerialization.data(withJSONObject: data, options: [.fragmentsAllowed]) + dataJSON = String(data: dataData, encoding: .utf8) ?? "{}" + } + } catch { + failures.append(( + rule: "\(rule)", + data: "\(data)", + expected: "\(expected)", + actual: "N/A", + error: "Failed to serialize test case: \(error)" + )) + continue + } + + do { + let result: Any? = try applyRuleReturningAny(ruleJSON, to: dataJSON) + + let isEqual = areEqual(result, expected) + if !isEqual { + failures.append(( + rule: ruleJSON, + data: dataJSON ?? "null", + expected: describeValue(expected), + actual: describeValue(result), + error: nil + )) + } else { + passCount += 1 + } + } catch { + failures.append(( + rule: ruleJSON, + data: dataJSON ?? "null", + expected: describeValue(expected), + actual: "N/A", + error: "Exception: \(error)" + )) + } + } + + // Report results + print("\n========== SPEC COMPLIANCE RESULTS ==========") + print("Passed: \(passCount)/\(specTests.count)") + print("Failed: \(failures.count)") + + if !failures.isEmpty { + print("\n---------- FAILURES ----------") + for (index, failure) in failures.enumerated() { + print("\n[\(index + 1)] Rule: \(failure.rule)") + print(" Data: \(failure.data)") + print(" Expected: \(failure.expected)") + print(" Actual: \(failure.actual)") + if let error = failure.error { + print(" Error: \(error)") + } + } + } + + XCTAssertEqual(failures.count, 0, "Spec compliance failures: \(failures.count) out of \(specTests.count) tests") + } + + // MARK: - Helper Methods + + private func applyRuleReturningAny(_ rule: String, to data: String?) throws -> Any? { + let logic = try JsonLogic(rule) + + // Try different return types to get the actual result + if let result: Bool = try? logic.applyRule(to: data) { + return result + } + if let result: Int = try? logic.applyRule(to: data) { + return result + } + if let result: Double = try? logic.applyRule(to: data) { + return result + } + if let result: String = try? logic.applyRule(to: data) { + return result + } + if let result: [Any?] = try? logic.applyRule(to: data) { + return result + } + if let result: [String: Any?] = try? logic.applyRule(to: data) { + return result + } + + // Try to get Any? directly + let result: Any? = try logic.applyRule(to: data) + return result + } + + private func areEqual(_ a: Any?, _ b: Any?) -> Bool { + // Normalize both values first + let normA = normalize(a) + let normB = normalize(b) + + // Handle nil/null cases + if normA == nil && normB == nil { + return true + } + guard let valA = normA, let valB = normB else { + return false + } + + // Check if values are booleans (important: must check before numbers due to NSNumber bridging) + let aIsBool = isBoolValue(valA) + let bIsBool = isBoolValue(valB) + + if aIsBool && bIsBool { + return getBool(valA) == getBool(valB) + } + + // If one is bool and other is not, they're not equal + if aIsBool != bIsBool { + return false + } + + // Compare numbers + if let numA = getNumber(valA), let numB = getNumber(valB) { + // Check if both are integers + if numA.truncatingRemainder(dividingBy: 1) == 0 && numB.truncatingRemainder(dividingBy: 1) == 0 { + return Int(numA) == Int(numB) + } + return abs(numA - numB) < 0.0001 + } + + // Compare strings + if let strA = valA as? String, let strB = valB as? String { + return strA == strB + } + + // Compare arrays + if let arrA = getArray(valA), let arrB = getArray(valB) { + guard arrA.count == arrB.count else { return false } + for (itemA, itemB) in zip(arrA, arrB) { + if !areEqual(itemA, itemB) { + return false + } + } + return true + } + + // Compare dictionaries + if let dictA = valA as? [String: Any], let dictB = valB as? [String: Any] { + guard dictA.count == dictB.count else { return false } + for (key, valueA) in dictA { + guard let valueB = dictB[key] else { return false } + if !areEqual(valueA, valueB) { + return false + } + } + return true + } + + return false + } + + private func normalize(_ value: Any?) -> Any? { + guard let value = value else { return nil } + + // Handle NSNull + if value is NSNull { return nil } + + // Try to extract from Optional using Mirror + let mirror = Mirror(reflecting: value) + if mirror.displayStyle == .optional { + if let child = mirror.children.first { + return normalize(child.value) + } + return nil + } + + return value + } + + private func isBoolValue(_ value: Any) -> Bool { + // NSNumber boolean detection - must check before Swift Bool because NSNumber bridges to Bool + if let num = value as? NSNumber { + // Only CFBoolean instances are true booleans + return num === kCFBooleanTrue || num === kCFBooleanFalse + } + + // Swift Bool type (for non-NSNumber bools) + if value is Bool { return true } + + return false + } + + private func getBool(_ value: Any) -> Bool? { + if let b = value as? Bool { return b } + if let num = value as? NSNumber, CFGetTypeID(num) == CFBooleanGetTypeID() { + return num.boolValue + } + return nil + } + + private func getNumber(_ value: Any) -> Double? { + if let i = value as? Int { return Double(i) } + if let d = value as? Double { return d } + if let f = value as? Float { return Double(f) } + if let num = value as? NSNumber { + // Check if it's actually a boolean - CFBoolean has a specific type + if CFGetTypeID(num) == CFBooleanGetTypeID() { + return nil + } + return num.doubleValue + } + return nil + } + + private func getArray(_ value: Any) -> [Any]? { + if let arr = value as? [Any] { return arr } + if let arr = value as? [Any?] { return arr.map { $0 as Any } } + return nil + } + + private func isBooleanNSNumber(_ value: Any) -> Bool { + // Swift Bool will match this + if value is Bool { + return true + } + // NSNumber from JSON that represents boolean + if let num = value as? NSNumber { + return CFGetTypeID(num) == CFBooleanGetTypeID() + } + return false + } + + private func unwrap(_ value: Any?) -> Any? { + guard let value = value else { return nil } + + // Try to deeply unwrap Optional types + let mirror = Mirror(reflecting: value) + if mirror.displayStyle == .optional { + if let child = mirror.children.first { + return unwrap(child.value) + } + return nil + } + + // Handle the case where value is Optional but mirror doesn't show it + // This happens when the value is boxed in Any + if let opt = value as? Any?, let inner = opt { + // Recursively unwrap if it's still wrapped + let innerMirror = Mirror(reflecting: inner) + if innerMirror.displayStyle == .optional { + return unwrap(inner) + } + return inner + } + + return value + } + + private func isNumber(_ value: Any) -> Bool { + return value is Int || value is Double || value is Float || value is Int64 || value is Int32 + } + + private func asInt(_ value: Any) -> Int? { + // Check for NSNumber first (covers most JSON-parsed numbers) + if let n = value as? NSNumber { + // Don't convert booleans to int + if CFGetTypeID(n) == CFBooleanGetTypeID() { + return nil + } + if n.doubleValue == floor(n.doubleValue) { + return n.intValue + } + return nil + } + if let i = value as? Int { return i } + if let i = value as? Int64 { return Int(i) } + if let i = value as? Int32 { return Int(i) } + if let d = value as? Double, d == floor(d) { return Int(d) } + return nil + } + + private func asDouble(_ value: Any) -> Double? { + if let d = value as? Double { return d } + if let f = value as? Float { return Double(f) } + if let i = value as? Int { return Double(i) } + if let i = value as? Int64 { return Double(i) } + if let n = value as? NSNumber { + // Don't convert booleans to double + if CFGetTypeID(n) == CFBooleanGetTypeID() { + return nil + } + return n.doubleValue + } + return nil + } + + private func describeValue(_ value: Any?) -> String { + guard let value = value else { return "nil" } + + let unwrapped = unwrap(value) + guard let val = unwrapped else { return "nil" } + + if val is NSNull { return "null" } + + // Check for NSNumber and distinguish booleans from integers + if let num = val as? NSNumber { + if CFGetTypeID(num) == CFBooleanGetTypeID() { + return num.boolValue ? "true" : "false" + } + // Check if it's an integer or double + if num.doubleValue == floor(num.doubleValue) { + return "\(num.intValue)" + } + return "\(num.doubleValue)" + } + + if let s = val as? String { return "\"\(s)\"" } + if let arr = val as? [Any] { + let items = arr.map { describeValue($0) }.joined(separator: ", ") + return "[\(items)]" + } + if let arr = val as? [Any?] { + let items = arr.map { describeValue($0) }.joined(separator: ", ") + return "[\(items)]" + } + if let dict = val as? [String: Any] { + return "\(dict)" + } + + return "\(val) (\(type(of: val)))" + } +} diff --git a/Tests/jsonlogicTests/TestUtils.swift b/Tests/jsonlogicTests/TestUtils.swift index 519727b..4116bb3 100644 --- a/Tests/jsonlogicTests/TestUtils.swift +++ b/Tests/jsonlogicTests/TestUtils.swift @@ -10,7 +10,7 @@ import XCTest //Implementation that allows for throwing errors from error handler block func _XCTAssertThrowsError(_ expression: @autoclosure () throws -> T, _ message: @autoclosure () -> String = "", - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line, _ errorHandler: (_ error: Swift.Error) throws -> Void) rethrows {