Compare commits

...

9 Commits

Author SHA1 Message Date
Kelvin Harron 088c021cbc Update README.md for SPM instructions
specified the target to select as part of the SPM installation process
2023-06-03 14:39:07 +02:00
Matyáš Kříž 475322e609 Bump version. 2023-04-17 17:30:01 +02:00
nanashiki 9676d7e374 Up DEPLOYMENT_TARGETs 2023-04-17 17:27:11 +02:00
nanashiki 75a209c3ba Fix String index is out of bounds crash 2023-04-17 17:24:25 +02:00
Matyáš Kříž e29f10eac6 Bump version. 2023-04-11 23:19:48 +02:00
Kabir Oberai 4148a6a7ff Support effectful properties 2023-04-11 23:17:51 +02:00
Kabir Oberai a71ba74df1 Fix generator crashing for property wrappers with empty type. 2023-04-01 10:19:13 +02:00
Matyáš Kříž fa66b6a0a0 Bump version. 2023-03-26 14:22:04 +02:00
Seth Deckard 6f41152d83
Fix Generator to handle reserved keywords
Allows for reserved keywords to be used as argument labels / parameters
in mocked types.

Fixes: https://github.com/Brightify/Cuckoo/issues/452

- Moves escaping util function to Utils and makes it internal since it
  now needs to be used outside of `Generator`.
- Uses escaping function in areas affected by the bug.
- Renames set of reserved names to reflect the larger scope (no longer
  used for just function names).
- Some minor clean-up to style and formatting on areas touched.

Example of the problem:

Generate a mock for a protocol with the function:

```
func escape(for: String) -> String
```

This would result in `for` as a function parameter used within the body
of the function for the mock and verification and stubbing proxies,
which would not compile. This change escapes keywords like this so the
generated code will compile.

See `TestedProtocol.withReservedKeywords` for a test example. An example
was added to `TestedClass` as well, but the real motivation for this is
protocols since conforming types can and often do use a different
parameter name so the argument label like `for` would never be used
within the body of the function.
2023-03-23 08:31:12 -04:00
18 changed files with 579 additions and 247 deletions

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = "Cuckoo"
s.version = "1.10.0"
s.version = "1.10.3"
s.summary = "Cuckoo - first boilerplate-free Swift mocking framework."
s.description = <<-DESC
Cuckoo is a mocking framework with an easy to use API (inspired by Mockito).

File diff suppressed because it is too large Load Diff

View File

@ -2,18 +2,6 @@ import Foundation
import Stencil
public struct Generator {
private static let reservedKeywordsNotAllowedAsMethodName: Set = [
// Keywords used in declarations:
"associatedtype", "class", "deinit", "enum", "extension", "fileprivate", "func", "import", "init", "inout", "internal", "let", "operator", "private", "precedencegroup", "protocol", "public", "rethrows", "static", "struct", "subscript", "typealias", "var",
// Keywords used in statements:
"break", "case", "catch", "continue", "default", "defer", "do", "else", "fallthrough", "for", "guard", "if", "in", "repeat", "return", "throw", "switch", "where", "while",
// Keywords used in expressions and types:
"Any", "as", "catch", "false", "is", "nil", "rethrows", "self", "super", "throw", "throws", "true", "try",
// Keywords used in patterns:
"_",
]
private let declarations: [Token]
public init(file: FileRepresentation) {
@ -56,10 +44,10 @@ public struct Generator {
guard let parameters = value as? [MethodParameter] else { return value }
return self.closeNestedClosure(for: parameters)
}
ext.registerFilter("escapeReservedKeywords") { (value: Any?) in
guard let name = value as? String else { return value }
return self.escapeReservedKeywords(for: name)
return escapeReservedKeywords(for: name)
}
ext.registerFilter("removeClosureArgumentNames") { (value: Any?) in
@ -110,7 +98,13 @@ public struct Generator {
guard parameters.isEmpty == false else { return "let matchers: [Cuckoo.ParameterMatcher<Void>] = []" }
let tupleType = parameters.map { $0.typeWithoutAttributes }.joined(separator: ", ")
let matchers = parameters.enumerated().map { "wrap(matchable: \($1.name)) { $0\(parameters.count > 1 ? ".\($0)" : "") }" }.joined(separator: ", ")
let matchers = parameters.enumerated().map { index, parameter in
let name = escapeReservedKeywords(for: parameter.name)
return "wrap(matchable: \(name)) { $0\(parameters.count > 1 ? ".\(index)" : "") }"
}
.joined(separator: ", ")
return "let matchers: [Cuckoo.ParameterMatcher<(\(genericSafeType(from: tupleType)))>] = [\(matchers)]"
}
@ -151,10 +145,6 @@ public struct Generator {
}
return fullString
}
private func escapeReservedKeywords(for name: String) -> String {
Self.reservedKeywordsNotAllowedAsMethodName.contains(name) ? "`\(name)`" : name
}
private func removeClosureArgumentNames(for type: String) -> String {
type.replacingOccurrences(

View File

@ -60,15 +60,15 @@ extension {{ container.parentFullyQualifiedName }} {
{{ attribute.text }}
{% endfor %}
{{ property.accessibility }}{% if container.isImplementation %} override{% endif %} var {{ property.name }}: {{ property.type }} {
get {
return cuckoo_manager.getter("{{ property.name }}",
get{% if property.isAsync %} async{% endif %}{% if property.isThrowing %} throws{% endif %} {
return {% if property.isThrowing %}try {% endif %}{% if property.isAsync %}await {% endif %}cuckoo_manager.getter{% if property.isThrowing %}Throws{% endif %}("{{ property.name }}",
superclassCall:
{% if container.isImplementation %}
super.{{ property.name }}
{% if property.isThrowing %}try {% endif %}{% if property.isAsync %}await {% endif %}super.{{ property.name }}
{% else %}
Cuckoo.MockManager.crashOnProtocolSuperclassCall()
{% endif %},
defaultCall: __defaultImplStub!.{{property.name}})
defaultCall: {% if property.isThrowing %}try {% endif %}{% if property.isAsync %}await {% endif %} __defaultImplStub!.{{property.name}})
}
{% ifnot property.isReadOnly %}
set {

View File

@ -185,11 +185,19 @@ public struct Tokenizer {
guessedType = type
}
let effects: InstanceVariable.Effects
if let bodyRange = bodyRange {
effects = parseEffects(source: source.utf8[bodyRange])
} else {
effects = .init()
}
return InstanceVariable(
name: name,
type: guessedType ?? .type("__UnknownType"),
accessibility: accessibility,
setterAccessibility: setterAccessibility,
effects: effects,
range: range!,
nameRange: nameRange!,
overriding: false,
@ -518,6 +526,34 @@ public struct Tokenizer {
return ReturnSignature(isAsync: isAsync, throwString: throwString, returnType: returnType ?? WrappableType.type("Void"), whereConstraints: whereConstraints)
}
private func parseEffects(source: String) -> InstanceVariable.Effects {
var effects = InstanceVariable.Effects()
let trimmed = source.drop(while: { $0.isWhitespace })
guard trimmed.hasPrefix("get") else { return effects }
let afterGet = trimmed.dropFirst("get".count).drop(while: { $0.isWhitespace })
var index = afterGet.startIndex
parseLoop: while index != afterGet.endIndex {
let character = afterGet[index]
switch character {
case "a":
effects.isAsync = true
index = source.index(index, offsetBy: "async".count)
case "t":
effects.isThrowing = true
index = source.index(index, offsetBy: "throws".count)
case let c where c.isWhitespace:
break
default:
break parseLoop
}
index = source.index(after: index)
}
return effects
}
// FIXME: Remove when SourceKitten fixes the off-by-one error that includes the ending `>` in the last inherited type.
private func fixSourceKittenLastGenericParameterBug(_ genericParameters: [GenericParameter]) -> [GenericParameter] {
let fixedGenericParameters: [GenericParameter]

View File

@ -1,8 +1,14 @@
public struct InstanceVariable: Token, HasAccessibility, HasAttributes {
public struct Effects {
public var isThrowing = false
public var isAsync = false
}
public var name: String
public var type: WrappableType
public var accessibility: Accessibility
public var setterAccessibility: Accessibility?
public var effects: Effects
public var range: CountableRange<Int>
public var nameRange: CountableRange<Int>
public var overriding: Bool
@ -22,8 +28,10 @@ public struct InstanceVariable: Token, HasAccessibility, HasAttributes {
}
public func serialize() -> [String : Any] {
let readOnlyString = readOnly ? "ReadOnly" : ""
let readOnlyVerifyString = readOnly ? "ReadOnly" : ""
let readOnlyStubString = effects.isThrowing ? "" : readOnlyVerifyString
let optionalString = type.isOptional && !readOnly ? "Optional" : ""
let throwingString = effects.isThrowing ? "Throwing" : ""
return [
"name": name,
@ -31,8 +39,10 @@ public struct InstanceVariable: Token, HasAccessibility, HasAttributes {
"nonOptionalType": type.unoptionaled.sugarized,
"accessibility": accessibility.sourceName,
"isReadOnly": readOnly,
"stubType": (overriding ? "Class" : "Protocol") + "ToBeStubbed\(readOnlyString)\(optionalString)Property",
"verifyType": "Verify\(readOnlyString)\(optionalString)Property",
"isAsync": effects.isAsync,
"isThrowing": effects.isThrowing,
"stubType": (overriding ? "Class" : "Protocol") + "ToBeStubbed\(readOnlyStubString)\(optionalString)\(throwingString)Property",
"verifyType": "Verify\(readOnlyVerifyString)\(optionalString)Property",
"attributes": attributes.filter { $0.isSupported },
"hasUnavailablePlatforms": hasUnavailablePlatforms,
"unavailablePlatformsCheck": unavailablePlatformsCheck,

View File

@ -38,11 +38,11 @@ public extension Method {
.map { $0 + ": " + $1 }
.joined(separator: ", ") + lastNamePart + returnSignatureString
}
var isAsync: Bool {
return returnSignature.isAsync
}
var isThrowing: Bool {
guard let throwType = returnSignature.throwType else { return false }
return throwType.isThrowing || throwType.isRethrowing
@ -67,7 +67,9 @@ public extension Method {
func serialize() -> [String : Any] {
let call = parameters.map {
let referencedName = "\($0.isInout ? "&" : "")\($0.name)\($0.isAutoClosure ? "()" : "")"
let name = escapeReservedKeywords(for: $0.name)
let referencedName = "\($0.isInout ? "&" : "")\(name)\($0.isAutoClosure ? "()" : "")"
if let label = $0.label {
return "\(label): \(referencedName)"
} else {
@ -95,7 +97,7 @@ public extension Method {
}
return "{ \(parameterSignature)\(returnSignature) in fatalError(\"This is a stub! It's not supposed to be called!\") }"
} else {
return parameter.name
return escapeReservedKeywords(for: parameter.name)
}
}.joined(separator: ", ")
@ -108,7 +110,7 @@ public extension Method {
"accessibility": accessibility.sourceName,
"returnSignature": returnSignature.description,
"parameters": parameters,
"parameterNames": parameters.map { $0.name }.joined(separator: ", "),
"parameterNames": parameters.map { escapeReservedKeywords(for: $0.name) }.joined(separator: ", "),
"escapingParameterNames": escapingParameterNames,
"isInit": isInit,
"returnType": returnType.explicitOptionalOnly.sugarized,

View File

@ -3,6 +3,8 @@ import Foundation
struct TypeGuesser {
static func guessType(from value: String) -> String? {
let value = value.trimmed
guard !value.isEmpty else { return nil }
let casting = checkCasting(from: value)
guard casting == nil else { return casting }

View File

@ -48,6 +48,26 @@ extension Sequence {
}
}
/// Reserved keywords that are not allowed as function names, function parameters, or local variables, etc.
fileprivate let reservedKeywordsNotAllowed: Set = [
// Keywords used in declarations:
"associatedtype", "class", "deinit", "enum", "extension", "fileprivate", "func", "import", "init", "inout",
"internal", "let", "operator", "private", "precedencegroup", "protocol", "public", "rethrows", "static",
"struct", "subscript", "typealias", "var",
// Keywords used in statements:
"break", "case", "catch", "continue", "default", "defer", "do", "else", "fallthrough", "for", "guard", "if", "in",
"repeat", "return", "throw", "switch", "where", "while",
// Keywords used in expressions and types:
"Any", "as", "catch", "false", "is", "nil", "rethrows", "self", "super", "throw", "throws", "true", "try",
// Keywords used in patterns:
"_",
]
/// Utility function for escaping reserved keywords for a symbol name.
internal func escapeReservedKeywords(for name: String) -> String {
reservedKeywordsNotAllowed.contains(name) ? "`\(name)`" : name
}
internal func extractRange(from dictionary: [String: SourceKitRepresentable], offset: Key, length: Key) -> CountableRange<Int>? {
guard let offset = (dictionary[offset.rawValue] as? Int64).map(Int.init),
let length = (dictionary[length.rawValue] as? Int64).map(Int.init) else { return nil }

View File

@ -39,9 +39,9 @@ Due to the limitations mentioned above, unoverridable code structures are not su
## Requirements
Cuckoo works on the following platforms:
- **iOS 8+**
- **Mac OSX 10.9+**
- **tvOS 9+**
- **iOS 11+**
- **Mac OSX 10.13+**
- **tvOS 11+**
**watchOS** support is not yet possible due to missing XCTest library.
@ -102,7 +102,9 @@ Note: All paths in the Run script must be absolute. Variable `PROJECT_DIR` autom
1. In Xcode, navigate in menu: File > Swift Packages > Add Package Dependency
2. Add `https://github.com/Brightify/Cuckoo.git`
3. Select "Up to Next Major" with `1.9.1`
3. For the Dependency Rule, Select "Up to Next Major" with `1.9.1`. Click Add Package.
4. On the 'Choose Package Products for Cuckoo' dialog, under 'Add to Target', please ensure you select your Test target as it will not compile on the app target.
5. Click Add Package.
Cuckoo relies on a script that is currently not downloadable using SPM. However, for convenience, you can copy this line into the terminal to download the latest `run` script. If the `run` script changes in the future, you'll need to execute this command again.
```Bash

View File

@ -310,11 +310,27 @@ extension MockManager {
return call(getterName(property), parameters: Void(), escapingParameters: Void(), superclassCall: superclassCall(), defaultCall: defaultCall())
}
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public func getter<T>(_ property: String, superclassCall: @autoclosure () async -> T, defaultCall: @autoclosure () async -> T) async -> T {
return await call(getterName(property), parameters: Void(), escapingParameters: Void(), superclassCall: await superclassCall(), defaultCall: await defaultCall())
}
public func setter<T>(_ property: String, value: T, superclassCall: @autoclosure () -> Void, defaultCall: @autoclosure () -> Void) {
return call(setterName(property), parameters: value, escapingParameters: value, superclassCall: superclassCall(), defaultCall: defaultCall())
}
}
extension MockManager {
public func getterThrows<T>(_ property: String, superclassCall: @autoclosure () throws -> T, defaultCall: @autoclosure () throws -> T) throws -> T {
return try callThrows(getterName(property), parameters: Void(), escapingParameters: Void(), superclassCall: try superclassCall(), defaultCall: try defaultCall())
}
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public func getterThrows<T>(_ property: String, superclassCall: @autoclosure () async throws -> T, defaultCall: @autoclosure () async throws -> T) async throws -> T {
return try await callThrows(getterName(property), parameters: Void(), escapingParameters: Void(), superclassCall: try await superclassCall(), defaultCall: try await defaultCall())
}
}
extension MockManager {
public func call<IN, OUT>(_ method: String, parameters: IN, escapingParameters: IN, superclassCall: @autoclosure () -> OUT, defaultCall: @autoclosure () -> OUT) -> OUT {
return callInternal(method, parameters: parameters, escapingParameters: escapingParameters, superclassCall: superclassCall, defaultCall: defaultCall)

View File

@ -0,0 +1,42 @@
//
// ToBeStubbedThrowingProperty.swift
// Cuckoo
//
// Created by Kabir Oberai on 2023-03-27.
//
public protocol ToBeStubbedThrowingProperty {
associatedtype GetterType: StubThrowingFunction
var get: GetterType { get }
}
public struct ProtocolToBeStubbedThrowingProperty<MOCK: ProtocolMock, T>: ToBeStubbedThrowingProperty {
private let manager: MockManager
private let name: String
public var get: ProtocolStubThrowingFunction<Void, T> {
return ProtocolStubThrowingFunction(stub:
manager.createStub(for: MOCK.self, method: getterName(name), parameterMatchers: []))
}
public init(manager: MockManager, name: String) {
self.manager = manager
self.name = name
}
}
public struct ClassToBeStubbedThrowingProperty<MOCK: ClassMock, T>: ToBeStubbedThrowingProperty {
private let manager: MockManager
private let name: String
public var get: ClassStubThrowingFunction<Void, T> {
return ClassStubThrowingFunction(stub:
manager.createStub(for: MOCK.self, method: getterName(name), parameterMatchers: []))
}
public init(manager: MockManager, name: String) {
self.manager = manager
self.name = name
}
}

View File

@ -58,6 +58,61 @@ class ProtocolTest: XCTestCase {
verify(mock).optionalProperty.set(equal(to: 0))
}
func testThrowingProperty() {
stub(mock) { mock in
when(mock.throwsProperty.get).thenReturn(5)
}
XCTAssertEqual(try mock.throwsProperty, 5)
verify(mock).throwsProperty.get()
clearInvocations(mock)
stub(mock) { mock in
when(mock.throwsProperty.get).thenThrow(TestError.unknown)
}
XCTAssertThrowsError(try mock.throwsProperty)
verify(mock).throwsProperty.get()
}
func testAsyncProperty() async {
stub(mock) { mock in
when(mock.asyncProperty.get).thenReturn(5)
}
let result = await mock.asyncProperty
XCTAssertEqual(result, 5)
verify(mock).asyncProperty.get()
}
func testAsyncThrowingProperty() async {
stub(mock) { mock in
when(mock.asyncThrowsProperty.get).thenReturn(5)
}
let result = try! await mock.asyncThrowsProperty
XCTAssertEqual(result, 5)
verify(mock).asyncThrowsProperty.get()
clearInvocations(mock)
stub(mock) { mock in
when(mock.asyncThrowsProperty.get).thenThrow(TestError.unknown)
}
var threw = false
do {
_ = try await mock.asyncThrowsProperty
} catch {
threw = true
}
XCTAssertTrue(threw)
verify(mock).asyncThrowsProperty.get()
}
func testNoReturn() {
var called = false
stub(mock) { mock in

View File

@ -0,0 +1,33 @@
//
// PropertyWrappers.swift
// Cuckoo
//
// Created by Kabir Oberai on 2023-03-28.
//
// wrappers without annotations aren't supported but their
// existence shouldn't cause the generator to crash
@propertyWrapper
struct DoublingWrapper {
private var backing: Int
var wrappedValue: Int {
get { backing * 2 }
set { backing = newValue }
}
init(wrappedValue: Int) {
self.backing = wrappedValue
}
init() {
self.backing = 0
}
}
struct Wrappers {
@DoublingWrapper var base
@DoublingWrapper var withValue = 2
@DoublingWrapper var withAnnotation: Int
@DoublingWrapper var withAnnotationAndValue: Int = 2
}

View File

@ -27,6 +27,11 @@ class TestedClass {
return "a"
}
var readOnlyPropertyWithJapaneseComment: String {
//
return "a"
}
@available(iOS 42.0, *)
var unavailableProperty: UnavailableProtocol? {
return nil
@ -53,6 +58,18 @@ class TestedClass {
) -> String
) -> () = { i in }
var asyncProperty: Int {
get async { 0 }
}
var asyncThrowsProperty: Int {
get async throws { 0 }
}
var throwsProperty: Int {
get throws { 0 }
}
func noReturn() {
}
@ -148,6 +165,10 @@ class TestedClass {
func withLabelAndUnderscore(labelA a: String, _ b: String) {
}
func withReservedKeywords(for: String, in: String) -> String {
"hello"
}
func callingCountCharactersMethodWithHello() -> Int {
return count(characters: "Hello")
}

View File

@ -34,6 +34,14 @@ protocol TestedProtocol {
) -> String
) -> () { get set }
var throwsProperty: Int { get throws }
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
var asyncProperty: Int { get async }
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
var asyncThrowsProperty: Int { get async throws }
func noReturn()
func count(characters: String) -> Int
@ -56,6 +64,14 @@ protocol TestedProtocol {
func withLabelAndUnderscore(labelA a: String, _ b: String)
/// In this example `for` and `in` are not actually used in a way that conflicts with reserved keywords because
/// conforming types will typically use `for` and `in` as an argument label for parameter with a different name,
/// thus avoiding the usage of a reserved keyword in the body of the function.
///
/// The problem was with the generated mock code, which was in turn using these in the body without escaping them,
/// causing the generated mock code to fail to compile.
func withReservedKeywords(for: String, in: String) -> String
func withNamedTuple(tuple: (a: String, b: String)) -> Int
func withImplicitlyUnwrappedOptional(i: Int!) -> String

View File

@ -124,6 +124,55 @@ class StubbingTest: XCTestCase {
XCTAssertEqual(mock.protocolMethod(), "a1")
}
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
func testEffectfulProps() async {
let mock = MockTestedSubSubClass()
XCTAssertNotNil(mock)
stub(mock) { stub in
when(stub.asyncProperty.get).thenReturn(5)
when(stub.throwsProperty.get).thenReturn(6)
when(stub.asyncThrowsProperty.get).thenReturn(7)
}
let resultAsync = await mock.asyncProperty
XCTAssertEqual(resultAsync, 5)
let resultThrows = try! mock.throwsProperty
XCTAssertEqual(resultThrows, 6)
let resultAsyncThrows = try! await mock.asyncThrowsProperty
XCTAssertEqual(resultAsyncThrows, 7)
verify(mock, times(1)).asyncProperty.get()
verify(mock, times(1)).throwsProperty.get()
verify(mock, times(1)).asyncThrowsProperty.get()
enum TestError: Error { case fromThrows, fromAsyncThrows }
stub(mock) { stub in
when(stub.throwsProperty.get).thenThrow(TestError.fromThrows)
when(stub.asyncThrowsProperty.get).thenThrow(TestError.fromAsyncThrows)
}
var caughtFromThrows = false
do {
_ = try mock.throwsProperty
} catch TestError.fromThrows {
caughtFromThrows = true
} catch {}
XCTAssert(caughtFromThrows)
var caughtFromAsyncThrows = false
do {
_ = try await mock.asyncThrowsProperty
} catch TestError.fromAsyncThrows {
caughtFromAsyncThrows = true
} catch {}
XCTAssert(caughtFromAsyncThrows)
}
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
func testAsyncMethods() async {

View File

@ -19,11 +19,11 @@ public enum PlatformType: String {
public var libraryDeploymentTarget: DeploymentTarget {
switch self {
case .iOS:
return .iOS(targetVersion: "8.0", devices: [.iphone, .ipad])
return .iOS(targetVersion: "11.0", devices: [.iphone, .ipad])
case .macOS:
return .macOS(targetVersion: "10.9")
return .macOS(targetVersion: "10.13")
case .tvOS:
return .tvOS(targetVersion: "9.0")
return .tvOS(targetVersion: "11.0")
}
}