diff --git a/Artifacts/CradleDiagramMaker.artifactbundle/CradleDiagramMaker-arm64/bin/CradleDiagramMaker b/Artifacts/CradleDiagramMaker.artifactbundle/CradleDiagramMaker-arm64/bin/CradleDiagramMaker index 85334dd..939d391 100755 Binary files a/Artifacts/CradleDiagramMaker.artifactbundle/CradleDiagramMaker-arm64/bin/CradleDiagramMaker and b/Artifacts/CradleDiagramMaker.artifactbundle/CradleDiagramMaker-arm64/bin/CradleDiagramMaker differ diff --git a/Artifacts/CradleDiagramMaker.artifactbundle/CradleDiagramMaker-x86_64/bin/CradleDiagramMaker b/Artifacts/CradleDiagramMaker.artifactbundle/CradleDiagramMaker-x86_64/bin/CradleDiagramMaker index 141e56b..dc24333 100755 Binary files a/Artifacts/CradleDiagramMaker.artifactbundle/CradleDiagramMaker-x86_64/bin/CradleDiagramMaker and b/Artifacts/CradleDiagramMaker.artifactbundle/CradleDiagramMaker-x86_64/bin/CradleDiagramMaker differ diff --git a/README.md b/README.md index 37c9348..1d98455 100644 --- a/README.md +++ b/README.md @@ -112,14 +112,14 @@ let viewModel = graph.userProfileViewModel( ) ``` -`@Provide`는 반환 타입을 기준으로 Factory를 연결해요. 기본값은 graph마다 한 번 만들고 계속 쓰며, `.transient`는 접근할 때마다 Factory를 다시 호출해요. `@External`은 graph가 만들 수 없는 호출 시점 값에 붙여요. +`@Provide`는 반환 타입을 기준으로 Factory를 연결해요. 기본값은 graph마다 한 번 만들고 계속 쓰며 `.lazy`는 생성 프로퍼티를 처음 읽을 때 한 번 만들고 `.transient`는 접근할 때마다 Factory를 다시 호출해요. `@External`은 graph가 만들 수 없는 호출 시점 값에 붙여요. Cradle은 graph를 만들 때 누락한 등록, 중복된 등록, 순환 의존성처럼 연결할 수 없는 구성을 컴파일 단계에서 알려줘요. Factory 본문에서 하는 임의 호출이나 실행 중 상태까지 검사하지는 않아요.
테스트와 Mermaid 산출물 추가하기 -`CradleTesting`은 `DependencyOverride.mock` 편의 API를 제공해요. 테스트에서 `.mock`을 쓰려면 `CradleTesting`을, graph 선언도 한다면 `Cradle`을 같은 test target에 추가해요. `.replace`를 직접 쓰는 경우에는 `Cradle`만 필요해요. +`CradleTesting`은 `DependencyOverride.mock` 편의 API를 제공해요. 테스트에서 `.mock`을 쓰려면 같은 test target에 `CradleTesting`을 추가해요. graph도 선언한다면 `Cradle`도 추가해요. `.replace`를 직접 쓰는 경우에는 `Cradle`만 필요해요. ```swift .testTarget( @@ -152,7 +152,7 @@ Cradle은 graph를 만들 때 누락한 등록, 중복된 등록, 순환 의존
Xcode에서 Mermaid 파일 열기 -`CradlePlugin`은 build 때 Mermaid 원본을 만들어요. 외부 Xcode 프로젝트에서 파일을 바로 열려면 먼저 [CopyCradleMermaid.sh](Examples/ExampleApp/Scripts/CopyCradleMermaid.sh)를 소비자 프로젝트의 `Scripts/CopyCradleMermaid.sh`로 복사해요. Mermaid가 필요한 같은 target에 `CradlePlugin`을 연결한 뒤, target의 마지막 Run Script 단계에서 다음 명령을 실행하고 Based on dependency analysis를 선택 해제해요. +`CradlePlugin`은 build 때 Mermaid 원본을 만들어요. 외부 Xcode 프로젝트에서 파일을 바로 열려면 먼저 [CopyCradleMermaid.sh](Examples/ExampleApp/Scripts/CopyCradleMermaid.sh)를 소비자 프로젝트의 `Scripts/CopyCradleMermaid.sh`로 복사해요. Mermaid가 필요한 같은 target에 `CradlePlugin`을 연결한 뒤 target의 마지막 Run Script 단계에서 다음 명령을 실행하고 Based on dependency analysis를 선택 해제해요. ```sh /bin/sh "${SRCROOT}/Scripts/CopyCradleMermaid.sh" diff --git a/Sources/Cradle/Cradle.docc/Cradle.md b/Sources/Cradle/Cradle.docc/Cradle.md index 9bb8c27..a110692 100644 --- a/Sources/Cradle/Cradle.docc/Cradle.md +++ b/Sources/Cradle/Cradle.docc/Cradle.md @@ -10,11 +10,11 @@ Factory 매개변수는 이름이 아니라 타입으로 다른 등록과 연결 Factory가 `any UserRepository`를 반환하면 graph의 `userRepository`도 같은 프로토콜 타입을 노출합니다. 구현 타입의 프로토콜 적합성은 Swift 컴파일러가 검사합니다. -기본 `@Provide`와 `@Provide(.shared)`는 graph 생성 중 한 번 만든 값을 해당 graph가 보유하고 이후 같은 값을 반환합니다. 이 값은 전역 싱글턴이 아니라 graph 인스턴스마다 분리됩니다. 프로퍼티를 읽을 때마다 Factory를 호출해야 하면 `@Provide(.transient)`를 사용합니다. +기본 `@Provide`와 `@Provide(.shared)`는 graph 생성 중 한 번 만든 값을 해당 graph가 보유하고 이후 같은 값을 반환합니다. `@Provide(.lazy)`는 생성 프로퍼티를 처음 읽을 때 값을 만들고 해당 graph가 보유합니다. 이 값들은 전역 싱글턴이 아니라 graph 인스턴스마다 분리됩니다. 프로퍼티를 읽을 때마다 Factory를 호출해야 하면 `@Provide(.transient)`를 사용합니다. -actor graph의 생성 프로퍼티는 actor 격리를 따릅니다. actor 밖에서는 `await`로 읽으며, 반환 값이 actor 경계를 통과할 수 있는지는 Swift 컴파일러가 `Sendable` 규칙으로 검사합니다. +actor graph의 생성 프로퍼티는 actor 격리를 따릅니다. actor 밖에서는 `await`로 읽으며 반환 값이 actor 경계를 통과할 수 있는지는 Swift 컴파일러가 `Sendable` 규칙으로 검사합니다. -`@DependencyGraph(overrides: true)`를 지정하면 등록별 기본값이 `.original`인 static `override`와 `OverrideBuilder.build()`를 사용할 수 있습니다. builder는 교체 선택만 보관하고 `.build()`에서 graph와 shared 등록을 만듭니다. actor 교체 Factory는 `@Sendable`이어야 하며, `@MainActor` graph의 builder는 같은 격리를 따릅니다. +`@DependencyGraph(overrides: true)`를 지정하면 등록별 기본값이 `.original`인 static `override`와 `OverrideBuilder.build()`를 사용할 수 있습니다. builder는 교체 선택만 보관하고 `.build()`에서 graph와 shared 등록을 만듭니다. lazy 교체 Factory는 생성 프로퍼티를 처음 읽을 때 실행합니다. actor 교체 Factory는 `@Sendable`이어야 하며, `@MainActor` graph의 builder는 같은 격리를 따릅니다. class graph는 동시 접근을 조정하지 않습니다. 여러 Task에서 공유해야 하면 단일 소유자로 사용하거나 `@MainActor`처럼 명시한 전역 actor 격리 안에 둡니다. diff --git a/Sources/Cradle/Cradle.docc/DependencyGraph.md b/Sources/Cradle/Cradle.docc/DependencyGraph.md index a1f8eb3..55629ef 100644 --- a/Sources/Cradle/Cradle.docc/DependencyGraph.md +++ b/Sources/Cradle/Cradle.docc/DependencyGraph.md @@ -1,10 +1,10 @@ # DependencyGraph로 의존성 등록 -`@DependencyGraph`와 `@Provide`를 함께 사용하면 Factory의 반환 타입별 읽기 전용 프로퍼티를 graph에 추가합니다. `@Provide`는 의존성을 등록하고, graph 프로퍼티는 등록된 의존성을 읽는 지점입니다. +`@DependencyGraph`와 `@Provide`를 함께 사용하면 Factory의 반환 타입별 읽기 전용 프로퍼티를 graph에 추가합니다. `@Provide`는 의존성을 등록하고 graph 프로퍼티는 등록된 의존성을 읽는 지점입니다. ## 기본 선언 -Factory의 이름은 자유롭게 정할 수 있습니다. 반환 타입이 등록 타입과 생성 프로퍼티의 타입이 되며, 반환 타입의 마지막 식별자를 lowerCamelCase로 바꾼 이름이 프로퍼티가 됩니다. +Factory의 이름은 자유롭게 정할 수 있습니다. 반환 타입이 등록 타입과 생성 프로퍼티의 타입이 되며 반환 타입의 마지막 식별자를 lowerCamelCase로 바꾼 이름이 프로퍼티가 됩니다. ```swift import Cradle @@ -27,7 +27,7 @@ let graph = AppGraph() let client = graph.httpClient ``` -기본 `@Provide`는 graph 생성 중 한 번 만드는 shared 등록입니다. `graph.httpClient`를 여러 번 읽어도 같은 값을 반환하며, 이 값은 전역 싱글턴이 아니라 해당 graph 인스턴스에만 보관됩니다. 접근할 때마다 새 값을 만들어야 하면 `@Provide(.transient)`를 사용합니다. +기본 `@Provide`는 graph 생성 중 한 번 만드는 shared 등록입니다. `graph.httpClient`를 여러 번 읽어도 같은 값을 반환하며 이 값은 전역 싱글턴이 아니라 해당 graph 인스턴스에만 보관됩니다. `@Provide(.lazy)`는 생성 프로퍼티를 처음 읽을 때 graph별 값을 한 번 만들고 보관합니다. 접근할 때마다 새 값을 만들어야 하면 `@Provide(.transient)`를 사용합니다. | 반환 타입 | 생성 프로퍼티 | | --- | --- | @@ -73,9 +73,9 @@ let graph = AppGraph.override( ).build() ``` -`OverrideBuilder`는 교체 Factory와 `.original` 선택만 보관합니다. graph와 shared 등록은 `.build()`를 호출할 때 처음 만들며, 같은 builder로 여러 번 `.build()`하면 서로 다른 graph와 shared 저장소를 얻습니다. shared 교체 Factory는 graph마다 한 번 실행하고, transient 교체 Factory는 생성 프로퍼티를 읽을 때마다 실행합니다. +`OverrideBuilder`는 교체 Factory와 `.original` 선택만 보관합니다. graph와 shared 등록은 `.build()`를 호출할 때 처음 만들며 같은 builder로 여러 번 `.build()`하면 서로 다른 graph와 shared 저장소를 얻습니다. shared 교체 Factory는 graph마다 한 번 실행하고 lazy 교체 Factory는 생성 프로퍼티를 처음 읽을 때 graph마다 한 번 실행하며 transient 교체 Factory는 생성 프로퍼티를 읽을 때마다 실행합니다. -builder를 보관하는 동안에는 builder가 선택한 모든 교체 Factory와 capture를 보관합니다. `.build()` 뒤 graph는 transient 교체 Factory와 그 capture만 graph가 해제될 때까지 보관합니다. shared 교체 Factory는 결과를 만든 뒤 graph에 보관하지 않습니다. 따라서 Factory가 graph 또는 builder를 capture하면 참조 순환이 생기지 않도록 수명을 직접 확인해야 합니다. +builder를 보관하는 동안에는 builder가 선택한 모든 교체 Factory와 capture를 보관합니다. `.build()` 뒤 graph는 transient 교체 Factory와 그 capture를 graph가 해제될 때까지 보관합니다. lazy 교체 Factory와 capture는 첫 결과를 만든 직후 graph에서 놓습니다. shared 교체 Factory는 `.build()`에서 결과를 만든 뒤 graph에 보관하지 않습니다. 따라서 Factory가 graph 또는 builder를 capture하면 참조 순환이 생기지 않도록 수명을 직접 확인해야 합니다. `.replace` closure의 매개변수 타입·순서·반환 타입은 원래 `@Provide` Factory와 같습니다. 잘못된 매개변수 또는 반환 타입, 존재하지 않거나 중복한 argument label은 Swift 컴파일러가 closure 또는 호출 원본 위치에서 오류를 표시합니다. `Optional`, `Any`, 문자열 key, 전역 등록소는 교체 경로에 사용하지 않습니다. @@ -83,7 +83,7 @@ builder를 보관하는 동안에는 builder가 선택한 모든 교체 Factory ## actor graph -`@DependencyGraph`는 비 generic actor에도 적용할 수 있습니다. actor graph에서 만든 생성 프로퍼티는 actor-isolated 상태로 남으므로 actor 밖에서는 `await`로 읽습니다. shared 등록은 class graph와 마찬가지로 graph 인스턴스별 타입 지정 `let` 저장소에 한 번 만들고, transient 등록은 접근할 때마다 Factory를 다시 호출합니다. +`@DependencyGraph`는 비 generic actor에도 적용할 수 있습니다. actor graph에서 만든 생성 프로퍼티는 actor-isolated 상태로 남으므로 actor 밖에서는 `await`로 읽습니다. shared 등록은 class graph와 마찬가지로 graph 인스턴스별 타입 지정 `let` 저장소에 한 번 만들고 lazy 등록은 actor 격리 안에서 첫 접근에 한 번 만들고 transient 등록은 접근할 때마다 Factory를 다시 호출합니다. ```swift import Cradle @@ -171,11 +171,11 @@ let graph = FeatureGraph.override( let feature = graph.feature ``` -`sources` 배열의 순서는 의미가 없습니다. Macro는 source type의 정규화한 이름순으로 저장 프로퍼티와 initializer 매개변수를 생성하므로, 위 예시의 initializer 매개변수도 `appGraph`, `sessionGraph` 순서입니다. 같은 source type을 중복하거나 서로 같은 저장 프로퍼티 이름을 만들면 오류를 냅니다. +`sources` 배열의 순서는 의미가 없습니다. Macro는 source type의 정규화한 이름순으로 저장 프로퍼티와 initializer 매개변수를 생성하므로 위 예시의 initializer 매개변수도 `appGraph`, `sessionGraph` 순서입니다. 같은 source type을 중복하거나 서로 같은 저장 프로퍼티 이름을 만들면 오류를 냅니다. 조합 graph는 source graph를 강하게 보관합니다. source graph의 shared 생성 프로퍼티는 source graph마다 같은 값을 반환합니다. 조합 graph의 transient Factory는 source graph의 transient 생성 프로퍼티를 읽을 때마다 source Factory를 다시 호출합니다. -조합 graph의 shared Factory도 source graph 생성 프로퍼티를 본문에서 직접 읽을 수 있습니다. Macro는 source 저장 프로퍼티를 먼저 대입한 뒤 실제로 참조한 source graph만 shared 저장소 생성기에 전달합니다. source graph의 transient 생성 프로퍼티는 이 생성기가 graph 초기화 중 실행될 때 평가되고, 결과는 조합 graph의 shared 값에 보관됩니다. source 생성 프로퍼티가 없거나 접근 수준이 맞지 않거나 반환 타입이 맞지 않으면 Macro가 대신 연결하지 않으며 Swift 컴파일러가 원본 Factory 본문에서 오류를 표시합니다. +조합 graph의 shared Factory도 source graph 생성 프로퍼티를 본문에서 직접 읽을 수 있습니다. Macro는 source 저장 프로퍼티를 먼저 대입한 뒤 실제로 참조한 source graph만 shared 저장소 생성기에 전달합니다. source graph의 transient 생성 프로퍼티는 이 생성기가 graph 초기화 중 실행될 때 평가되고 결과는 조합 graph의 shared 값에 보관됩니다. lazy Factory는 source graph 참조를 바꾸지 않고 생성 프로퍼티를 처음 읽을 때 원래 본문을 실행하므로 source graph의 transient 값도 그 시점에 한 번 읽습니다. source 생성 프로퍼티가 없거나 접근 수준이 맞지 않거나 반환 타입이 맞지 않으면 Macro가 대신 연결하지 않으며 Swift 컴파일러가 원본 Factory 본문에서 오류를 표시합니다. ## 타입으로 의존성 연결 @@ -230,7 +230,7 @@ let client = AppGraph().httpClient ## 호출 시점 외부 입력 -화면 식별자처럼 graph를 만들 때 정할 수 없는 값은 `@External`로 표시합니다. 이름 충돌을 피해야 할 때는 `@Cradle.External`로 한정할 수 있습니다. 두 표기는 명시적인 `@Provide(.transient)` Factory 매개변수에서만 사용할 수 있습니다. Macro는 표시하지 않은 매개변수를 타입으로 graph 등록에 연결하고, 표시한 매개변수만 받는 생성 메서드를 만듭니다. +화면 식별자처럼 graph를 만들 때 정할 수 없는 값은 `@External`로 표시합니다. 이름 충돌을 피해야 할 때는 `@Cradle.External`로 한정할 수 있습니다. 두 표기는 명시적인 `@Provide(.transient)` Factory 매개변수에서만 사용할 수 있습니다. Macro는 표시하지 않은 매개변수를 타입으로 graph 등록에 연결하고 표시한 매개변수만 받는 생성 메서드를 만듭니다. ```swift import Cradle @@ -275,11 +275,11 @@ let viewModel = graph.userProfileViewModel(id: UserID(rawValue: 29)) 생성 메서드는 호출할 때마다 Factory를 실행하며 graph는 결과를 보관하지 않습니다. 같은 입력을 반복해서 전달해도 Factory가 같은 인스턴스나 값을 반환하는지는 보장하지 않습니다. `External` wrapper도 생성 메서드 서명이나 반환 결과에 노출하거나 저장하지 않습니다. -본문 없는 Factory에서도 같은 규칙을 사용합니다. Macro는 graph 의존성과 외부 입력을 원래 선언 순서대로 반환 타입 initializer에 전달하고, 생성 메서드는 외부 입력만 받습니다. +본문 없는 Factory에서도 같은 규칙을 사용합니다. Macro는 graph 의존성과 외부 입력을 원래 선언 순서대로 반환 타입 initializer에 전달하고 생성 메서드는 외부 입력만 받습니다. 외부 입력이 있는 Factory의 반환 타입은 일반 graph 등록이 아닙니다. 따라서 생성 프로퍼티, shared 저장소, 자동 의존성 연결과 순환 검사 간선에 포함되지 않습니다. 다른 Factory가 이 반환 타입을 매개변수로 요구하면 Macro는 생성 메서드를 직접 호출해야 한다는 오류를 표시합니다. 등록이 빠진 매개변수를 외부 입력으로 추론하지도 않습니다. -`overrides: true` graph에서 `.original`은 원본 Factory를 사용합니다. `.replace` Factory는 graph 의존성과 외부 입력을 포함한 원래 매개변수 타입과 순서를 모두 유지하며, 외부 입력은 생성 메서드를 호출할 때 전달합니다. +`overrides: true` graph에서 `.original`은 원본 Factory를 사용합니다. `.replace` Factory는 graph 의존성과 외부 입력을 포함한 원래 매개변수 타입과 순서를 모두 유지하며 외부 입력은 생성 메서드를 호출할 때 전달합니다. ```swift let graph = AppGraph.override( @@ -297,7 +297,7 @@ actor graph의 생성 메서드는 actor 격리를 유지하므로 actor 밖에 ## 프로토콜과 superclass 반환 타입 -Factory는 protocol 또는 superclass를 반환 타입으로 선언할 수 있습니다. 생성 프로퍼티도 선언한 반환 타입을 그대로 노출하고, 구현 타입이 그 타입에 대입 가능한지는 Swift 컴파일러가 검사합니다. +Factory는 protocol 또는 superclass를 반환 타입으로 선언할 수 있습니다. 생성 프로퍼티도 선언한 반환 타입을 그대로 노출하고 구현 타입이 그 타입에 대입 가능한지는 Swift 컴파일러가 검사합니다. ```swift import Cradle @@ -333,9 +333,9 @@ let profile = graph.profile `P`와 `any P`는 연결과 중복 검사에서 같은 등록 타입으로 취급합니다. Macro는 `typealias`가 가리키는 실제 타입, import로 생략한 모듈 경로, protocol·superclass 선언의 의미를 해석하지 않습니다. -## shared 수명과 transient 수명 +## shared 수명과 lazy 수명, transient 수명 -`@Provide`와 `@Provide(.shared)`는 graph를 만들 때 Factory 결과를 한 번 생성하고, graph 전용의 타입 지정 `let` 저장소가 이를 보유하게 합니다. 같은 graph에서 해당 생성 프로퍼티를 여러 번 읽으면 같은 값을 반환합니다. graph가 해제되면 저장소가 보유한 참조도 함께 놓습니다. 이 수명은 전역 싱글턴이 아니라 graph 인스턴스별 수명입니다. +`@Provide`와 `@Provide(.shared)`는 graph를 만들 때 Factory 결과를 한 번 생성하고 graph 전용의 타입 지정 `let` 저장소가 이를 보유하게 합니다. 같은 graph에서 해당 생성 프로퍼티를 여러 번 읽으면 같은 값을 반환합니다. graph가 해제되면 저장소가 보유한 참조도 함께 놓습니다. 이 수명은 전역 싱글턴이 아니라 graph 인스턴스별 수명입니다. ```swift @DependencyGraph @@ -351,13 +351,15 @@ let first = graph.userRepository let second = graph.userRepository ``` -프로퍼티를 읽을 때마다 Factory를 호출해야 하면 `@Provide(.transient)`를 사용합니다. transient Factory는 shared와 transient 등록을 매개변수로 받을 수 있습니다. +`@Provide(.lazy)`는 graph 인스턴스의 타입 지정 `lazy var` 저장소에 결과를 보관합니다. graph를 만들 때는 Factory를 실행하지 않고 생성 프로퍼티를 처음 읽을 때 한 번 실행합니다. 같은 graph의 뒤이은 접근은 같은 값을 반환하며 graph가 해제되면 결과도 함께 놓습니다. lazy Factory는 일반 인스턴스 Factory이므로 첫 접근 시점의 `self`, graph 상태와 source graph를 직접 읽을 수 있습니다. -shared 수명의 Factory는 다른 shared 등록만 매개변수로 받을 수 있습니다. shared 수명의 Factory가 transient 등록을 받으면 그 transient 값이 graph 생성 때 한 번 만들어져 shared 값에 고정되므로, Macro는 해당 매개변수 타입 위치에 오류를 표시합니다. +프로퍼티를 읽을 때마다 Factory를 호출해야 하면 `@Provide(.transient)`를 사용합니다. transient Factory는 shared·lazy·transient 등록을 매개변수로 받을 수 있습니다. + +shared 수명의 Factory는 다른 shared 등록만 매개변수로 받을 수 있습니다. shared 수명의 Factory가 lazy 또는 transient 등록을 받으면 두 등록의 평가 시점이 shared 결과에 고정되므로, Macro는 해당 매개변수 타입 위치에 오류를 표시합니다. lazy 수명의 Factory는 shared 또는 lazy 등록을 받을 수 있지만 transient 등록은 보관할 수 없습니다. shared Factory 본문은 사용자가 작성한 initializer 본문보다 먼저 실행됩니다. 따라서 `self`, `super`, class·actor graph 인스턴스 멤버, 다른 Factory를 직접 참조할 수 없습니다. 필요한 shared 의존성은 Factory 매개변수로 선언합니다. actor graph의 shared Factory가 actor 상태를 읽으면 static helper에서 Swift 컴파일러가 오류를 표시합니다. -source graph 저장 프로퍼티는 shared Factory에서 직접 읽을 수 있습니다. Macro는 이 참조를 생성한 static helper의 매개변수로 바꾸고, source 저장 프로퍼티를 대입한 뒤 helper를 실행합니다. source graph의 transient 값을 읽으면 그 표현식은 조합 graph를 초기화할 때 한 번 평가되어 shared 결과에 보관됩니다. +source graph 저장 프로퍼티는 shared Factory에서 직접 읽을 수 있습니다. Macro는 이 참조를 생성한 static helper의 매개변수로 바꾸고 source 저장 프로퍼티를 대입한 뒤 helper를 실행합니다. source graph의 transient 값을 읽으면 그 표현식은 조합 graph를 초기화할 때 한 번 평가되어 shared 결과에 보관됩니다. ## Mermaid 개발 산출물 @@ -390,13 +392,13 @@ plugin은 macOS용 `CradleDiagramMaker` artifact를 실행합니다. 소비자 final class PreviewGraph {} ``` -Mermaid는 graph의 `sources` 선언, provider의 타입 의존성, Factory가 실제로 읽는 source 참조를 모두 실선 화살표로 그립니다. provider 간 연결은 해당 graph 안에서만 만듭니다. 바깥 graph 묶음은 이름 없이 provider를 모으고, graph 이름 node와 source node는 중립 실선 테두리입니다. `.shared` provider node는 실선 테두리, `.transient` provider node는 점선 테두리입니다. +Mermaid는 graph의 `sources` 선언, provider의 타입 의존성, Factory가 실제로 읽는 source 참조를 모두 실선 화살표로 그립니다. provider 간 연결은 해당 graph 안에서만 만듭니다. 바깥 graph 묶음은 이름 없이 provider를 모으고, graph 이름 node와 source node는 중립 실선 테두리입니다. `.shared` provider node는 실선 테두리, `.lazy` provider node는 짧은 점선 테두리, `.transient` provider node는 긴 점선 테두리입니다. source 타입은 명시한 lexical 경로가 일치하거나 바깥 lexical scope에서 같은 이름의 graph를 찾을 때 해당 graph에 연결합니다. 다른 target의 graph, 해석할 수 없는 타입, `diagram: false`로 제외한 graph는 내부 없이 source 이름만 표시합니다. module 접두사와 typealias는 의미 분석하지 않으며, 다른 scope의 이름 끝부분만 같다는 이유로 연결하지 않습니다. `@External` 입력이 있는 Factory는 graph가 자동으로 등록·연결하지 않는 호출 시점 생성 경로이므로 Mermaid node와 화살표에서 모두 제외합니다. runtime override 선택과 다른 target의 내부 등록은 분석하지 않습니다. -plugin은 실제 build condition을 평가하지 않습니다. source에 작성된 모든 `#if`·`#elseif`·`#else` 절을 함께 분석하므로 같은 lexical graph가 여러 절에 선언되면 source 위치를 포함한 오류로 build를 중단하고, 기존 성공 산출물은 유지합니다. 같은 내용의 `.mmd`는 다시 쓰지 않습니다. graph 삭제·이름 변경·`diagram: false` 전환은 다음 build에서 그림에 반영하며, 대상 graph가 없으면 파일도 제거합니다. 이전 방식으로 생성한 graph별 `.mmd`는 단일 파일 생성에 성공한 뒤 정리합니다. +plugin은 실제 build condition을 평가하지 않습니다. source에 작성된 모든 `#if`·`#elseif`·`#else` 절을 함께 분석하므로 같은 lexical graph가 여러 절에 선언되면 source 위치를 포함한 오류로 build를 중단하고 기존 성공 산출물은 유지합니다. 같은 내용의 `.mmd`는 다시 쓰지 않습니다. graph 삭제·이름 변경·`diagram: false` 전환은 다음 build에서 그림에 반영하며 대상 graph가 없으면 파일도 제거합니다. 이전 방식으로 생성한 graph별 `.mmd`는 단일 파일 생성에 성공한 뒤 정리합니다. ## 본문 없는 Factory @@ -433,7 +435,7 @@ Factory 반환 타입과 매개변수 타입에는 직접 작성한 Optional을 반환 타입은 프로퍼티 이름을 만들 수 있는 명목 타입 또는 `any`를 사용한 단일 protocol 타입이어야 합니다. `Array`와 `Dictionary`처럼 이름으로 작성한 제네릭 명목 타입은 사용할 수 있지만, `[Service]`, `[Key: Value]` 축약 문법은 지원하지 않습니다. 함수, 튜플, 메타타입, `some` 타입, protocol 조합도 등록 타입으로 지원하지 않습니다. `typealias`가 가리키는 실제 타입은 분석하지 않습니다. -유효한 graph에서는 누락 등록, 중복 등록, 기존 멤버 충돌, shared → transient 참조, 순환 의존성을 컴파일 중 진단합니다. 이런 오류가 있으면 관련 없는 등록을 포함해 graph 프로퍼티를 생성하지 않습니다. +유효한 graph에서는 누락 등록, 중복 등록, 기존 멤버 충돌, shared → lazy·transient 참조, lazy → transient 참조, 순환 의존성을 컴파일 중 진단합니다. 이런 오류가 있으면 관련 없는 등록을 포함해 graph 프로퍼티를 생성하지 않습니다. ## 접근 수준과 초기화 @@ -445,7 +447,7 @@ Factory 반환 타입과 매개변수 타입에는 직접 작성한 Optional을 ## 현재 지원 범위 -현재 `@DependencyGraph`는 동기 Factory의 타입 기반 연결, transient·shared 수명, 호출 시점 외부 입력 생성 메서드, graph 인스턴스별 Factory 교체를 지원합니다. 다음 기능은 아직 지원하지 않습니다. +현재 `@DependencyGraph`는 동기 Factory의 타입 기반 연결, shared·lazy·transient 수명, 호출 시점 외부 입력 생성 메서드, graph 인스턴스별 Factory 교체를 지원합니다. 다음 기능은 아직 지원하지 않습니다. - graph 생성 뒤 등록 교체 - source graph 생성 프로퍼티의 자동 주입 diff --git a/Sources/Cradle/DependencyGraph.swift b/Sources/Cradle/DependencyGraph.swift index 52cf900..8798c8b 100644 --- a/Sources/Cradle/DependencyGraph.swift +++ b/Sources/Cradle/DependencyGraph.swift @@ -31,7 +31,7 @@ public macro Provide() = #externalMacro( type: "ProvideMacro" ) -// `.shared`로 graph가 Factory 결과를 즉시 소유하도록 표시 +// Factory 결과를 graph 수명 정책에 맞게 소유하도록 표시 @attached(peer, names: arbitrary) @attached(body) public macro Provide(_ lifetime: DependencyLifetime) = #externalMacro( diff --git a/Sources/Cradle/DependencyLifetime.swift b/Sources/Cradle/DependencyLifetime.swift index 46187e3..1795d86 100644 --- a/Sources/Cradle/DependencyLifetime.swift +++ b/Sources/Cradle/DependencyLifetime.swift @@ -9,6 +9,8 @@ public enum DependencyLifetime: Sendable { // graph 생성 중 한 번 만들고 해당 graph에서 재사용 case shared + // 생성 프로퍼티를 처음 읽을 때 graph별로 한 번 생성 + case lazy // 생성 프로퍼티를 읽을 때마다 Factory를 호출 case transient } diff --git a/Sources/CradleGraphAnalysis/GraphDiagramParser.swift b/Sources/CradleGraphAnalysis/GraphDiagramParser.swift index 525a6c3..03fa818 100644 --- a/Sources/CradleGraphAnalysis/GraphDiagramParser.swift +++ b/Sources/CradleGraphAnalysis/GraphDiagramParser.swift @@ -306,11 +306,17 @@ private func graphProviderLifetime(in attribute: AttributeSyntax) -> GraphProvid let argument = arguments.first, argument.label == nil, let member = argument.expression.as(MemberAccessExprSyntax.self), - member.base == nil, - graphIdentifierName(member.declName.baseName) == "transient" else { + member.base == nil else { + return .shared + } + switch graphIdentifierName(member.declName.baseName) { + case "transient": + return .transient + case "lazy": + return .lazy + default: return .shared } - return .transient } // `@External` 또는 `@Cradle.External` marker 확인 diff --git a/Sources/CradleGraphAnalysis/GraphTypeIdentity.swift b/Sources/CradleGraphAnalysis/GraphTypeIdentity.swift index f82d287..03454d3 100644 --- a/Sources/CradleGraphAnalysis/GraphTypeIdentity.swift +++ b/Sources/CradleGraphAnalysis/GraphTypeIdentity.swift @@ -19,6 +19,8 @@ package enum GraphProviderLifetime: String { case transient // graph 생성 중 한 번 만들고 보관하는 수명 case shared + // 생성 프로퍼티를 처음 읽을 때 한 번 만들고 보관하는 수명 + case lazy } // 타입 연결과 정렬에 사용할 정규 identity 생성 diff --git a/Sources/CradleGraphAnalysis/MermaidDiagramRenderer.swift b/Sources/CradleGraphAnalysis/MermaidDiagramRenderer.swift index a92da00..4ace0a1 100644 --- a/Sources/CradleGraphAnalysis/MermaidDiagramRenderer.swift +++ b/Sources/CradleGraphAnalysis/MermaidDiagramRenderer.swift @@ -17,6 +17,7 @@ package func mermaidDiagram(for diagrams: [GraphDiagram], excludedNames: Set [RegisteredTypeIdentity: String] { - Dictionary(uniqueKeysWithValues: providers.map { ($0.registrationIdentity, $0.propertyName) }) -} - // graph 본체의 `@Provide` Factory 검증과 수집 private func providers( in members: MemberBlockItemListSyntax, @@ -375,24 +379,3 @@ private func providers( return (descriptors, hasError) } - -// shared 등록이 있을 때만 graph 전용 저장소 생성 -private func sharedStorage( - for providers: [ProviderDescriptor], - graphName: TokenSyntax, - sources: [SourceGraphDescriptor], - propertyNames: [RegisteredTypeIdentity: String], - in context: some MacroExpansionContext -) -> SharedGraphStorage? { - let sharedProviders = providers.filter { $0.lifetime == .shared } - guard !sharedProviders.isEmpty else { - return nil - } - return SharedGraphStorage( - graphName: graphName, - providers: sharedProviders, - sources: sources, - propertyNames: propertyNames, - in: context - ) -} diff --git a/Sources/CradleMacros/InvalidProviderLifetimeDiagnostic.swift b/Sources/CradleMacros/InvalidProviderLifetimeDiagnostic.swift index 100cb1a..1db711b 100644 --- a/Sources/CradleMacros/InvalidProviderLifetimeDiagnostic.swift +++ b/Sources/CradleMacros/InvalidProviderLifetimeDiagnostic.swift @@ -16,7 +16,7 @@ struct InvalidProviderLifetimeDiagnostic: DiagnosticMessage { // 지원하는 인자 생략과 직접 case 표기 안내 var message: String { - "`@Provide` 인자는 생략하거나 `.shared` 또는 `.transient`로 직접 지정해야 합니다." + "`@Provide` 인자는 생략하거나 `.shared`, `.lazy`, `.transient`로 직접 지정해야 합니다." } // 잘못된 수명 인자를 포함한 graph의 컴파일 중단 diff --git a/Sources/CradleMacros/LazyGraphStorage.swift b/Sources/CradleMacros/LazyGraphStorage.swift new file mode 100644 index 0000000..7b8a786 --- /dev/null +++ b/Sources/CradleMacros/LazyGraphStorage.swift @@ -0,0 +1,66 @@ +// +// LazyGraphStorage.swift +// CradleMacros +// +// Created by opfic on 9/6/26. +// + +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +// graph 인스턴스별 lazy Factory 결과를 보관하는 타입 지정 저장소 생성 +struct LazyGraphStorage { + // lazy 결과를 보관할 등록 + let providers: [ProviderDescriptor] + // 등록 타입 identity와 생성 프로퍼티 이름 연결 + let propertyNames: [RegisteredTypeIdentity: String] + // 등록별 충돌 없는 lazy 저장 프로퍼티 이름 + let storageNames: [RegisteredTypeIdentity: TokenSyntax] + + // lazy 등록과 호출 인자를 보관할 저장소 정보 생성 + init( + providers: [ProviderDescriptor], + propertyNames: [RegisteredTypeIdentity: String], + in context: some MacroExpansionContext + ) { + self.providers = providers.filter { $0.lifetime == .lazy } + self.propertyNames = propertyNames + storageNames = Dictionary(uniqueKeysWithValues: self.providers.map { provider in + ( + provider.registrationIdentity, + lazyStorageName(in: context) + ) + }) + } + + // graph가 보관할 모든 lazy 저장 프로퍼티 선언 생성 + func declarations() -> [DeclSyntax] { + providers.compactMap(storageDeclaration) + } + + // lazy 생성 프로퍼티가 읽을 graph 소유 결과 참조 + func valueReference(for provider: ProviderDescriptor) -> String? { + storageNames[provider.registrationIdentity]?.trimmedDescription + } + + // 원본 Factory 호출을 최초 접근에 실행하는 lazy 저장 프로퍼티 선언 + private func storageDeclaration(for provider: ProviderDescriptor) -> DeclSyntax? { + guard let storageName = storageNames[provider.registrationIdentity] else { + return nil + } + let arguments = provider.parameters.map { parameter in + parameter.factoryArgument(propertyName: propertyNames[parameter.typeIdentity]) + }.joined(separator: ", ") + return DeclSyntax( + """ + private lazy var \(storageName): \(raw: provider.returnType.trimmedDescription) = \(raw: provider.factoryName)(\(raw: arguments)) + """ + ) + } +} + +// 타입 이름과 무관하게 고유한 lazy 저장 식별자 생성 +private func lazyStorageName(in context: some MacroExpansionContext) -> TokenSyntax { + context.makeUniqueName("lazyStorage") +} diff --git a/Sources/CradleMacros/ProviderDeclaration.swift b/Sources/CradleMacros/ProviderDeclaration.swift index ed1b064..46c5853 100644 --- a/Sources/CradleMacros/ProviderDeclaration.swift +++ b/Sources/CradleMacros/ProviderDeclaration.swift @@ -7,13 +7,41 @@ import SwiftSyntax import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +// 등록 타입 identity와 생성 접근자 이름 연결 생성 +func propertyNames(for providers: [ProviderDescriptor]) -> [RegisteredTypeIdentity: String] { + Dictionary(uniqueKeysWithValues: providers.map { ($0.registrationIdentity, $0.propertyName) }) +} + +// shared 등록이 있을 때만 graph 전용 저장소 생성 +func sharedStorage( + for providers: [ProviderDescriptor], + graphName: TokenSyntax, + sources: [SourceGraphDescriptor], + propertyNames: [RegisteredTypeIdentity: String], + in context: some MacroExpansionContext +) -> SharedGraphStorage? { + let sharedProviders = providers.filter { $0.lifetime == .shared } + guard !sharedProviders.isEmpty else { + return nil + } + return SharedGraphStorage( + graphName: graphName, + providers: sharedProviders, + sources: sources, + propertyNames: propertyNames, + in: context + ) +} // 일반 생성 프로퍼티와 외부 입력 생성 메서드 선언 생성 func providerDeclarations( for providers: [ProviderDescriptor], accessLevel: AccessLevel, propertyNames: [RegisteredTypeIdentity: String], - storage: SharedGraphStorage? + storage: SharedGraphStorage?, + lazyStorage: LazyGraphStorage ) -> [DeclSyntax] { let declarations = providers.map { provider in if provider.hasExternalParameters { @@ -27,10 +55,11 @@ func providerDeclarations( for: provider, accessLevel: accessLevel, propertyNames: propertyNames, - storage: storage + storage: storage, + lazyStorage: lazyStorage ) } - return (storage?.declarations() ?? []) + declarations + return (storage?.declarations() ?? []) + lazyStorage.declarations() + declarations } // 호출자 입력만 노출하고 원본 Factory를 호출하는 생성 메서드 선언 @@ -62,7 +91,8 @@ private func propertyDeclaration( for provider: ProviderDescriptor, accessLevel: AccessLevel, propertyNames: [RegisteredTypeIdentity: String], - storage: SharedGraphStorage? + storage: SharedGraphStorage?, + lazyStorage: LazyGraphStorage ) -> DeclSyntax { let signature = "\(accessLevel.rawValue) var \(provider.propertyName)" if provider.lifetime == .shared, let storage { @@ -74,6 +104,16 @@ private func propertyDeclaration( """ ) } + if provider.lifetime == .lazy, + let valueReference = lazyStorage.valueReference(for: provider) { + return DeclSyntax( + """ + \(raw: signature): \(raw: provider.returnType.trimmedDescription) { + \(raw: valueReference) + } + """ + ) + } let arguments = provider.parameters.map { parameter in parameter.factoryArgument(propertyName: propertyNames[parameter.typeIdentity]) }.joined(separator: ", ") diff --git a/Sources/CradleMacros/ProviderLifetime.swift b/Sources/CradleMacros/ProviderLifetime.swift index 5e220a8..c17e2ca 100644 --- a/Sources/CradleMacros/ProviderLifetime.swift +++ b/Sources/CradleMacros/ProviderLifetime.swift @@ -15,6 +15,8 @@ enum ProviderLifetime { case transient // graph 생성 중 한 번 만들고 저장하는 기본 수명 case shared + // graph 생성 뒤 최초 접근에 결과를 보관하는 지연 수명 + case lazy } // 표현식 평가 없이 인자 생략 또는 직접 작성한 lifetime case만 허용 @@ -39,10 +41,17 @@ func providerLifetime( member.base == nil, member.declName.argumentNames == nil, let name = member.declName.baseName.identifier?.name, - name == "shared" || name == "transient", + name == "shared" || name == "lazy" || name == "transient", !attribute.hasError else { context.diagnose(Diagnostic(node: arguments, message: InvalidProviderLifetimeDiagnostic())) return nil } - return member.declName.baseName.identifier?.name == "shared" ? .shared : .transient + switch member.declName.baseName.identifier?.name { + case "shared": + return .shared + case "lazy": + return .lazy + default: + return .transient + } } diff --git a/Sources/CradleMacros/ProviderLifetimeReferenceDiagnostic.swift b/Sources/CradleMacros/ProviderLifetimeReferenceDiagnostic.swift new file mode 100644 index 0000000..2fda39f --- /dev/null +++ b/Sources/CradleMacros/ProviderLifetimeReferenceDiagnostic.swift @@ -0,0 +1,56 @@ +// +// ProviderLifetimeReferenceDiagnostic.swift +// CradleMacros +// +// Created by opfic on 9/6/26. +// + +import SwiftDiagnostics + +// graph 보관 수명 조합의 원본 매개변수 진단 +enum ProviderLifetimeReferenceDiagnostic: DiagnosticMessage { + // eager shared Factory가 lazy 등록을 앞당기는 연결 + case sharedLazy + // lazy Factory가 transient 결과를 보관하는 연결 + case lazyTransient + + // 수명 조합별 고정 진단 식별자 + var diagnosticID: MessageID { + switch self { + case .sharedLazy: + MessageID(domain: "Cradle", id: "invalidSharedLazyProviderReference") + case .lazyTransient: + MessageID(domain: "Cradle", id: "invalidLazyProviderReference") + } + } + + // 수명 조합별 소비자 안내 + var message: String { + switch self { + case .sharedLazy: + "shared 수명의 `@Provide` Factory는 `.lazy` 등록을 매개변수로 받을 수 없습니다." + case .lazyTransient: + "lazy 수명의 `@Provide` Factory는 `.transient` 등록을 매개변수로 받을 수 없습니다." + } + } + + // 컴파일 중단 오류 + var severity: DiagnosticSeverity { .error } +} + +// graph 보관 수명 조합의 원본 매개변수 진단 반환 +func providerLifetimeReferenceDiagnostic( + providerLifetime: ProviderLifetime, + dependencyLifetime: ProviderLifetime +) -> (any DiagnosticMessage)? { + switch (providerLifetime, dependencyLifetime) { + case (.shared, .transient): + InvalidSharedProviderReferenceDiagnostic() + case (.shared, .lazy): + ProviderLifetimeReferenceDiagnostic.sharedLazy + case (.lazy, .transient): + ProviderLifetimeReferenceDiagnostic.lazyTransient + default: + nil + } +} diff --git a/Sources/CradleMacros/TypedOverride.swift b/Sources/CradleMacros/TypedOverride.swift index 126dce5..de130f7 100644 --- a/Sources/CradleMacros/TypedOverride.swift +++ b/Sources/CradleMacros/TypedOverride.swift @@ -19,6 +19,10 @@ private struct TypedOverrideProvider { let stateName: TokenSyntax // builder와 graph 저장소가 공유할 선택 상태 이름 let storageName: TokenSyntax + // lazy 결과를 보관할 graph 저장 프로퍼티 이름 + let lazyValueName: TokenSyntax + // lazy 결과를 최초 평가할 graph helper 이름 + let lazyBuilderName: TokenSyntax // actor graph의 교체 Factory 동시성 경계 여부 let requiresSendableFactory: Bool @@ -48,12 +52,15 @@ func typedOverrideDeclarations( provider: provider, stateName: typedOverrideUniqueName("TypedOverrideState", in: context), storageName: typedOverrideUniqueName("typedOverrideState", in: context), + lazyValueName: typedOverrideUniqueName("typedOverrideLazyValue", in: context), + lazyBuilderName: typedOverrideUniqueName("makeTypedOverrideLazy", in: context), requiresSendableFactory: graph.isActor ) } let builderName = TokenSyntax.identifier("OverrideBuilder") let shared = overrides.filter { $0.provider.lifetime == .shared } let transient = overrides.filter { $0.provider.lifetime == .transient } + let lazy = overrides.filter { $0.provider.lifetime == .lazy } let sharedStorage = TypedOverrideSharedStorage( graphName: graph.name, builderName: builderName, @@ -76,6 +83,7 @@ func typedOverrideDeclarations( providers: overrides, sources: sources, transient: transient, + lazy: lazy, storage: sharedStorage, accessLevel: accessLevel, isActor: graph.isActor @@ -221,11 +229,13 @@ private func typedOverrideHasTypeMemberModifier(_ modifiers: DeclModifierListSyn // `DependencyOverride`를 graph 내부 선택 상태로 변환하는 enum 선언 private func selectionDeclaration(for override: TypedOverrideProvider) -> DeclSyntax { let sendable = override.requiresSendableFactory ? ": Sendable" : "" + let consumed = override.provider.lifetime == .lazy ? "\n case consumed" : "" return DeclSyntax( """ fileprivate enum \(override.stateName)\(raw: sendable) { case original case replace(\(raw: override.factoryType)) + \(raw: consumed) init(_ selection: DependencyOverride<\(raw: override.factoryType)>) { switch selection { @@ -315,6 +325,7 @@ private func graphInitializers( providers: [TypedOverrideProvider], sources: [SourceGraphDescriptor], transient: [TypedOverrideProvider], + lazy: [TypedOverrideProvider], storage: TypedOverrideSharedStorage, accessLevel: AccessLevel, isActor: Bool @@ -322,6 +333,9 @@ private func graphInitializers( let transientAssignments = transient.map { override in "self.\(override.storageName) = overrides.\(override.storageName)" }.joined(separator: "\n") + let lazyAssignments = lazy.map { override in + "self.\(override.storageName) = overrides.\(override.storageName)" + }.joined(separator: "\n") let storageAssignment = storage.initializationAssignment(sources: sources) let sourceParameters = sources.map { source in "\(source.propertyName): \(source.type.trimmedDescription)" @@ -349,6 +363,7 @@ private func graphInitializers( private init(\(raw: privateParameters)) { \(raw: sourceAssignments) \(raw: transientAssignments) + \(raw: lazyAssignments) \(raw: storageAssignment) } """ @@ -372,6 +387,9 @@ private func typedOverridePropertyDeclarations( let transientStorage = providers.filter { $0.provider.lifetime == .transient }.map { override in DeclSyntax("private let \(override.storageName): \(override.stateName)") } + let lazyStorage = providers.filter { $0.provider.lifetime == .lazy }.map { override in + DeclSyntax("private var \(override.storageName): \(override.stateName)") + } let properties = providers.map { override in if override.provider.hasExternalParameters { return typedOverrideExternalMethodDeclaration( @@ -388,7 +406,7 @@ private func typedOverridePropertyDeclarations( storage: storage ) } - return sourceStorage + transientStorage + storage.declarations() + properties + return sourceStorage + transientStorage + lazyStorage + storage.declarations() + properties } // 외부 입력을 호출 시점에 원본 또는 교체 Factory로 전달하는 생성 메서드 @@ -442,6 +460,13 @@ private func typedOverridePropertyDeclaration( } """) } + if provider.lifetime == .lazy { + return typedOverrideLazyPropertyDeclaration( + for: override, + accessLevel: accessLevel, + propertyNames: propertyNames + ) + } let originalArguments = provider.parameters.map { parameter in parameter.factoryArgument(propertyName: propertyNames[parameter.typeIdentity]) }.joined(separator: ", ") @@ -460,6 +485,48 @@ private func typedOverridePropertyDeclaration( """) } +// 최초 접근에서 원본 또는 교체 Factory를 평가하고 선택 상태를 해제하는 lazy 생성 프로퍼티 +private func typedOverrideLazyPropertyDeclaration( + for override: TypedOverrideProvider, + accessLevel: AccessLevel, + propertyNames: [RegisteredTypeIdentity: String] +) -> DeclSyntax { + let provider = override.provider + let signature = "\(accessLevel.rawValue) var \(provider.propertyName): \(provider.returnType.trimmedDescription)" + let originalArguments = provider.parameters.map { parameter in + parameter.factoryArgument( + propertyName: propertyNames[parameter.typeIdentity], + qualifyingGraphMember: true + ) + }.joined(separator: ", ") + let overrideArguments = provider.parameters.map { parameter in + parameter.factoryValue( + propertyName: propertyNames[parameter.typeIdentity], + qualifyingGraphMember: true + ) + }.joined(separator: ", ") + return DeclSyntax(""" + private lazy var \(override.lazyValueName): \(raw: provider.returnType.trimmedDescription) = \(override.lazyBuilderName)() + + private func \(override.lazyBuilderName)() -> \(raw: provider.returnType.trimmedDescription) { + let selection = \(override.storageName) + \(override.storageName) = .consumed + return switch selection { + case .original: + self.\(raw: provider.factoryName)(\(raw: originalArguments)) + case let .replace(factory): + factory(\(raw: overrideArguments)) + case .consumed: + Swift.preconditionFailure("lazy Factory selection was already consumed") + } + } + + \(raw: signature) { + \(override.lazyValueName) + } + """) +} + // override-enabled graph의 shared 결과 저장소 생성 private struct TypedOverrideSharedStorage { // shared 결과 저장 타입 이름 diff --git a/Sources/CradleTesting/CradleTesting.docc/CradleTesting.md b/Sources/CradleTesting/CradleTesting.docc/CradleTesting.md index 45fd6ab..9f98810 100644 --- a/Sources/CradleTesting/CradleTesting.docc/CradleTesting.md +++ b/Sources/CradleTesting/CradleTesting.docc/CradleTesting.md @@ -4,7 +4,7 @@ ## Overview -테스트 target은 graph 선언과 Macro를 위해 `Cradle`을, `.mock` 편의 API를 위해 `CradleTesting`을 함께 import합니다. `.mock`은 Factory를 실행하지 않고 기존 `.replace` 상태로 감싸며, graph 생성은 기존 `Graph.override(...).build()` 경로를 사용합니다. +테스트 target은 graph 선언과 Macro를 위해 `Cradle`을 import하고 `.mock` 편의 API를 위해 `CradleTesting`도 import합니다. `.mock`은 Factory를 실행하지 않고 기존 `.replace` 상태로 감싸며 graph 생성은 기존 `Graph.override(...).build()` 경로를 사용합니다. ```swift import Cradle diff --git a/Tests/CompileFixtures/LazyActorGraphNonSendableBoundary/Package.swift b/Tests/CompileFixtures/LazyActorGraphNonSendableBoundary/Package.swift new file mode 100644 index 0000000..b120e3c --- /dev/null +++ b/Tests/CompileFixtures/LazyActorGraphNonSendableBoundary/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 6.3 + +import PackageDescription + +// lazy actor graph의 소비자 compiler 경계 검증용 package +let package = Package( + name: "LazyActorGraphNonSendableBoundary", + platforms: [.macOS(.v10_15)], + dependencies: [ + .package(path: "../../..") + ], + targets: [ + .executableTarget( + name: "LazyActorGraphNonSendableBoundary", + dependencies: [ + .product(name: "Cradle", package: "Cradle") + ] + ) + ], + swiftLanguageModes: [.v6] +) diff --git a/Tests/CompileFixtures/LazyActorGraphNonSendableBoundary/Sources/LazyActorGraphNonSendableBoundary/main.swift b/Tests/CompileFixtures/LazyActorGraphNonSendableBoundary/Sources/LazyActorGraphNonSendableBoundary/main.swift new file mode 100644 index 0000000..34603ce --- /dev/null +++ b/Tests/CompileFixtures/LazyActorGraphNonSendableBoundary/Sources/LazyActorGraphNonSendableBoundary/main.swift @@ -0,0 +1,26 @@ +import Cradle + +final class LazyActorNonSendableService {} + +// actor 안에서는 허용하되 밖으로 반환할 수 없는 lazy 등록 graph +@DependencyGraph +actor LazyActorGraphNonSendableBoundary { + @Provide(.lazy) + private func makeLazyActorNonSendableService() -> LazyActorNonSendableService { + LazyActorNonSendableService() + } +} + +// actor 밖에서 non-Sendable lazy 결과를 읽는 오류 확인 +func readLazyActorService( + from graph: LazyActorGraphNonSendableBoundary +) async -> LazyActorNonSendableService { + await graph.lazyActorNonSendableService +} + +// actor-isolated lazy 생성 프로퍼티를 await 없이 읽는 오류 확인 +func readLazyActorServiceSynchronously( + from graph: LazyActorGraphNonSendableBoundary +) -> LazyActorNonSendableService { + graph.lazyActorNonSendableService +} diff --git a/Tests/CompileFixtures/LazyProviderInvalidUsage/Package.swift b/Tests/CompileFixtures/LazyProviderInvalidUsage/Package.swift new file mode 100644 index 0000000..6394ba8 --- /dev/null +++ b/Tests/CompileFixtures/LazyProviderInvalidUsage/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 6.3 + +import PackageDescription + +// lazy 수명 Macro 진단의 compiler 출력 검증용 package +let package = Package( + name: "LazyProviderInvalidUsage", + platforms: [.macOS(.v10_15)], + dependencies: [ + .package(path: "../../..") + ], + targets: [ + .executableTarget( + name: "LazyProviderInvalidUsage", + dependencies: [ + .product(name: "Cradle", package: "Cradle") + ] + ) + ], + swiftLanguageModes: [.v6] +) diff --git a/Tests/CompileFixtures/LazyProviderInvalidUsage/Sources/LazyProviderInvalidUsage/main.swift b/Tests/CompileFixtures/LazyProviderInvalidUsage/Sources/LazyProviderInvalidUsage/main.swift new file mode 100644 index 0000000..cc0f26d --- /dev/null +++ b/Tests/CompileFixtures/LazyProviderInvalidUsage/Sources/LazyProviderInvalidUsage/main.swift @@ -0,0 +1,68 @@ +import Cradle + +struct LazyProviderInvalidLeaf {} +struct LazyProviderInvalidBranch {} +struct LazyProviderInvalidCycleFirst {} +struct LazyProviderInvalidCycleSecond {} + +// shared가 lazy 등록을 앞당기는 연결 거부 확인 graph +@DependencyGraph +final class SharedLazyProviderInvalidGraph { + @Provide(.lazy) + private func makeLazyProviderInvalidLeaf() -> LazyProviderInvalidLeaf { + LazyProviderInvalidLeaf() + } + + @Provide(.shared) + private func makeLazyProviderInvalidBranch( + leaf: LazyProviderInvalidLeaf + ) -> LazyProviderInvalidBranch { + LazyProviderInvalidBranch() + } +} + +// lazy가 transient 결과를 보관하는 연결 거부 확인 graph +@DependencyGraph +final class LazyTransientProviderInvalidGraph { + @Provide(.transient) + private func makeLazyProviderInvalidLeaf() -> LazyProviderInvalidLeaf { + LazyProviderInvalidLeaf() + } + + @Provide(.lazy) + private func makeLazyProviderInvalidBranch( + leaf: LazyProviderInvalidLeaf + ) -> LazyProviderInvalidBranch { + LazyProviderInvalidBranch() + } +} + +// 외부 입력과 lazy 수명의 조합 거부 확인 graph +@DependencyGraph +final class LazyExternalProviderInvalidGraph { + @Provide(.lazy) + private func makeLazyProviderInvalidBranch( + @External value: Int + ) -> LazyProviderInvalidBranch { + _ = value + return LazyProviderInvalidBranch() + } +} + +// lazy 등록끼리 순환하는 연결 거부 확인 graph +@DependencyGraph +final class LazyProviderCycleInvalidGraph { + @Provide(.lazy) + private func makeLazyProviderInvalidCycleFirst( + second: LazyProviderInvalidCycleSecond + ) -> LazyProviderInvalidCycleFirst { + LazyProviderInvalidCycleFirst() + } + + @Provide(.lazy) + private func makeLazyProviderInvalidCycleSecond( + first: LazyProviderInvalidCycleFirst + ) -> LazyProviderInvalidCycleSecond { + LazyProviderInvalidCycleSecond() + } +} diff --git a/Tests/CompileFixtures/LazyProviderUnicodeStorage/Package.swift b/Tests/CompileFixtures/LazyProviderUnicodeStorage/Package.swift new file mode 100644 index 0000000..7d51b7f --- /dev/null +++ b/Tests/CompileFixtures/LazyProviderUnicodeStorage/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 6.3 + +import PackageDescription + +// Unicode 반환 타입의 lazy 저장소 이름 검증용 package +let package = Package( + name: "LazyProviderUnicodeStorage", + platforms: [.macOS(.v10_15)], + dependencies: [ + .package(path: "../../..") + ], + targets: [ + .executableTarget( + name: "LazyProviderUnicodeStorage", + dependencies: [ + .product(name: "Cradle", package: "Cradle") + ] + ) + ], + swiftLanguageModes: [.v6] +) diff --git a/Tests/CompileFixtures/LazyProviderUnicodeStorage/Sources/LazyProviderUnicodeStorage/main.swift b/Tests/CompileFixtures/LazyProviderUnicodeStorage/Sources/LazyProviderUnicodeStorage/main.swift new file mode 100644 index 0000000..1448a53 --- /dev/null +++ b/Tests/CompileFixtures/LazyProviderUnicodeStorage/Sources/LazyProviderUnicodeStorage/main.swift @@ -0,0 +1,24 @@ +// swiftlint:disable type_name +import Cradle + +final class Service🐱 {} +final class Service🐶 {} + +// 정규화하면 같은 철자가 되는 반환 타입을 함께 보관할 lazy graph +@DependencyGraph +final class LazyProviderUnicodeStorageGraph { + @Provide(.lazy) + private func makeService🐱() -> Service🐱 { + Service🐱() + } + + @Provide(.lazy) + private func makeService🐶() -> Service🐶 { + Service🐶() + } +} + +let graph = LazyProviderUnicodeStorageGraph() +_ = graph.service🐱 +_ = graph.service🐶 +// swiftlint:enable type_name diff --git a/Tests/CradleConsumerFixture/CradleTestingMockGraph.swift b/Tests/CradleConsumerFixture/CradleTestingMockGraph.swift index cd11fde..bc63eb7 100644 --- a/Tests/CradleConsumerFixture/CradleTestingMockGraph.swift +++ b/Tests/CradleConsumerFixture/CradleTestingMockGraph.swift @@ -135,6 +135,17 @@ public final class CradleTestingSharedService { } } +// lazy mock Factory가 반환할 graph별 참조 값 +public final class CradleTestingLazyService { + // lazy mock Factory의 검증값 + public let token: Int + + // lazy mock 결과 생성 + public init(token: Int) { + self.token = token + } +} + // shared 의존성을 받는 transient 결과 public struct CradleTestingTransientService { // transient 결과에 주입한 shared 참조 값 @@ -155,6 +166,12 @@ public final class CradleTestingLifetimeGraph { CradleTestingSharedService(token: 5) } + // 최초 접근에만 생성할 lazy 결과의 원본 Factory + @Provide(.lazy) + private func makeCradleTestingLazyService() -> CradleTestingLazyService { + CradleTestingLazyService(token: 6) + } + // transient 결과의 원본 Factory @Provide(.transient) private func makeCradleTestingTransientService( diff --git a/Tests/CradleConsumerFixture/PublicLazyGraph.swift b/Tests/CradleConsumerFixture/PublicLazyGraph.swift new file mode 100644 index 0000000..c923458 --- /dev/null +++ b/Tests/CradleConsumerFixture/PublicLazyGraph.swift @@ -0,0 +1,32 @@ +// +// PublicLazyGraph.swift +// CradleConsumerFixture +// +// Created by opfic on 9/6/26. +// + +import Cradle + +// 외부 module에 노출할 lazy 참조 값 +public final class PublicLazyService { + // 외부 검증값 + public let token: Int + + // 공개 lazy 결과 생성 + public init(token: Int) { + self.token = token + } +} + +// 외부 module의 public lazy 접근자 검증용 graph +@DependencyGraph +public final class PublicLazyGraph { + // 외부 graph 생성 허용 initializer + public init() {} + + // 최초 접근에 생성할 공개 lazy 결과 + @Provide(.lazy) + private func makePublicLazyService() -> PublicLazyService { + PublicLazyService(token: 41) + } +} diff --git a/Tests/CradleDiagramMakerSupportTests/DiagramOutputWriterTests.swift b/Tests/CradleDiagramMakerSupportTests/DiagramOutputWriterTests.swift index 6e8c1a2..05c4466 100644 --- a/Tests/CradleDiagramMakerSupportTests/DiagramOutputWriterTests.swift +++ b/Tests/CradleDiagramMakerSupportTests/DiagramOutputWriterTests.swift @@ -281,6 +281,37 @@ func diagramOutputWriterRendersSourceDependenciesAndLifetimeBorders() throws { #expect(mermaid.contains("classDef transient stroke:#333,stroke-width:2px,stroke-dasharray:5 5;")) } +// lazy provider 수명 테두리를 Mermaid 산출물에 표현하는지 확인 +@Test +func diagramOutputWriterRendersLazyLifetimeBorder() throws { + let temporary = try makeDiagramTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporary) } + let source = temporary.appendingPathComponent("AppGraph.swift") + try """ + @DependencyGraph + final class AppGraph { + @Provide(.lazy) + private func makeLazyFeature() -> LazyFeature { + LazyFeature() + } + } + """.write(to: source, atomically: true, encoding: .utf8) + + let output = try #require( + DiagramOutputWriter().write( + request: DiagramOutputRequest( + moduleName: "AppComposition", + sourceURLs: [source], + outputDirectoryURL: temporary.appendingPathComponent("CradleDiagrams") + ) + ).first + ) + let mermaid = try String(contentsOf: output, encoding: .utf8) + + #expect(mermaid.contains("classDef lazy stroke:#333,stroke-width:2px,stroke-dasharray:2 3;")) + #expect(mermaid.contains("LazyFeature
makeLazyFeature
.lazy")) +} + // 구문 오류가 있으면 직전 성공 산출물을 유지하는지 확인 @Test func diagramOutputWriterPreservesExistingOutputWhenSourceIsInvalid() throws { diff --git a/Tests/CradleGraphAnalysisTests/GraphDiagramParserTests.swift b/Tests/CradleGraphAnalysisTests/GraphDiagramParserTests.swift index 690242e..0356a10 100644 --- a/Tests/CradleGraphAnalysisTests/GraphDiagramParserTests.swift +++ b/Tests/CradleGraphAnalysisTests/GraphDiagramParserTests.swift @@ -53,6 +53,11 @@ func graphDiagramsCollectsGraphRelationships() { Feature(repository: repository) } + @Provide(.lazy) + private func makeLazyFeature(repository: Repository) -> LazyFeature { + LazyFeature(repository: repository) + } + @Provide(.transient) private func makeProfile(@External token: Token) -> Profile { Profile(token: token) @@ -65,10 +70,11 @@ func graphDiagramsCollectsGraphRelationships() { #expect(diagram.map(\.lexicalName) == ["AppGraph"]) #expect(diagram[0].sources.map(\.name) == ["sessionGraph"]) - #expect(diagram[0].providers.map(\.factoryName) == ["makeRepository", "makeFeature"]) - #expect(diagram[0].providers.map(\.lifetime) == [.shared, .transient]) + #expect(diagram[0].providers.map(\.factoryName) == ["makeRepository", "makeFeature", "makeLazyFeature"]) + #expect(diagram[0].providers.map(\.lifetime) == [.shared, .transient, .lazy]) #expect(diagram[0].providers[0].sourceNames == ["sessionGraph"]) #expect(diagram[0].providers[1].dependencyIdentities.map(\.canonicalText) == ["Repository"]) + #expect(diagram[0].providers[2].dependencyIdentities.map(\.canonicalText) == ["Repository"]) } // escaped transient 수명 인자를 Mermaid 수명으로 정규화하는지 확인 @@ -87,6 +93,22 @@ func graphDiagramsRecognizesEscapedTransientLifetime() { #expect(graphDiagrams(in: sourceFile)[0].providers.map(\.lifetime) == [.transient]) } +// escaped lazy 수명 인자를 Mermaid 수명으로 정규화하는지 확인 +@Test +func graphDiagramsRecognizesEscapedLazyLifetime() { + let sourceFile = Parser.parse( + source: """ + @DependencyGraph + final class AppGraph { + @Provide(.`lazy`) + private func makeFeature() -> Feature { Feature() } + } + """ + ) + + #expect(graphDiagrams(in: sourceFile)[0].providers.map(\.lifetime) == [.lazy]) +} + // `@External` Factory를 node와 연결에서 제외하고 본문 없는 일반 Factory는 포함하는지 확인 @Test func graphDiagramsExcludesExternalFactoryAndIncludesBodylessProvider() { diff --git a/Tests/CradleGraphAnalysisTests/GraphTypeIdentityTests.swift b/Tests/CradleGraphAnalysisTests/GraphTypeIdentityTests.swift index 06c8f14..e7b14fa 100644 --- a/Tests/CradleGraphAnalysisTests/GraphTypeIdentityTests.swift +++ b/Tests/CradleGraphAnalysisTests/GraphTypeIdentityTests.swift @@ -20,7 +20,16 @@ func graphTypeIdentityNormalizesAnyAndParentheses() { } // provider 수명의 원본 문자열을 보존하는지 확인 -@Test(arguments: [GraphProviderLifetime.shared, .transient]) +@Test(arguments: [GraphProviderLifetime.shared, .lazy, .transient]) func graphProviderLifetimePreservesRawValue(lifetime: GraphProviderLifetime) { - #expect(lifetime.rawValue == (lifetime == .shared ? "shared" : "transient")) + let expected = switch lifetime { + case .shared: + "shared" + case .lazy: + "lazy" + case .transient: + "transient" + } + + #expect(lifetime.rawValue == expected) } diff --git a/Tests/CradleGraphAnalysisTests/MermaidDiagramRendererTests.swift b/Tests/CradleGraphAnalysisTests/MermaidDiagramRendererTests.swift index 6e3f0fe..e93271f 100644 --- a/Tests/CradleGraphAnalysisTests/MermaidDiagramRendererTests.swift +++ b/Tests/CradleGraphAnalysisTests/MermaidDiagramRendererTests.swift @@ -176,6 +176,32 @@ func mermaidDiagramRendersSolidRelationshipsAndLifetimeBorders() { #expect(!mermaid.contains("-.->")) } +// lazy provider node의 수명 label과 테두리를 Mermaid로 표현하는지 확인 +@Test +func mermaidDiagramRendersLazyLifetimeBorder() { + let diagram = GraphDiagram( + lexicalName: "AppGraph", + sourceOffset: 0, + sources: [], + providers: [ + GraphDiagramProvider( + factoryName: "makeLazyFeature", + typeName: "LazyFeature", + identity: GraphTypeIdentity(canonicalText: "LazyFeature"), + lifetime: .lazy, + dependencyIdentities: [], + sourceNames: [] + ) + ] + ) + + let mermaid = mermaidDiagram(for: [diagram]) + + #expect(mermaid.contains("classDef lazy stroke:#333,stroke-width:2px,stroke-dasharray:2 3;")) + #expect(mermaid.contains("LazyFeature
makeLazyFeature
.lazy")) + #expect(mermaid.contains("class graph0_provider0 lazy")) +} + // Mermaid node ID가 사람이 읽는 label에 의존하지 않는지 확인 @Test func mermaidDiagramEscapesLabelsWithoutChangingNodeIDs() { diff --git a/Tests/CradleMacrosTests/ProviderLifetimeTests.swift b/Tests/CradleMacrosTests/ProviderLifetimeTests.swift index 242b935..67ca8d2 100644 --- a/Tests/CradleMacrosTests/ProviderLifetimeTests.swift +++ b/Tests/CradleMacrosTests/ProviderLifetimeTests.swift @@ -27,6 +27,14 @@ func providerLifetimeAcceptsShared(source: String) throws { #expect(context.diagnostics.isEmpty) } +// 주석과 백틱을 포함한 직접 lazy case 표기 허용 확인 +@Test(arguments: ["@Provide(.lazy)", "@Provide(/* 수명 */ .lazy)", "@Provide(.`lazy`)"]) +func providerLifetimeAcceptsLazy(source: String) throws { + let (attribute, context) = try lifetimeAttribute(source) + #expect(providerLifetime(from: attribute, in: context) == .lazy) + #expect(context.diagnostics.isEmpty) +} + // 주석과 백틱을 포함한 직접 transient case 표기 허용 확인 @Test(arguments: ["@Provide(.transient)", "@Provide(/* 수명 */ .transient)", "@Provide(.`transient`)"]) func providerLifetimeAcceptsTransient(source: String) throws { @@ -48,7 +56,7 @@ func providerLifetimeRejectsUnsupportedArguments(argument: String) throws { let diagnostic = try #require(context.diagnostics.first) #expect(diagnostic.diagnosticID == .init(domain: "Cradle", id: "invalidProviderLifetime")) #expect(diagnostic.diagMessage.severity == .error) - #expect(diagnostic.message == "`@Provide` 인자는 생략하거나 `.shared` 또는 `.transient`로 직접 지정해야 합니다.") + #expect(diagnostic.message == "`@Provide` 인자는 생략하거나 `.shared`, `.lazy`, `.transient`로 직접 지정해야 합니다.") #expect(diagnostic.node.trimmedDescription == argument) #expect(diagnostic.notes.isEmpty) #expect(diagnostic.fixIts.isEmpty) diff --git a/Tests/CradleMacrosTests/SharedGraphStorageMacroTests.swift b/Tests/CradleMacrosTests/SharedGraphStorageMacroTests.swift index 8b9d115..7088a04 100644 --- a/Tests/CradleMacrosTests/SharedGraphStorageMacroTests.swift +++ b/Tests/CradleMacrosTests/SharedGraphStorageMacroTests.swift @@ -105,6 +105,70 @@ func sharedGraphStorageRejectsTransientDependency() { ) } +// shared Factory가 lazy 등록을 앞당기는지 원본 타입 위치에서 거부 확인 +@Test +func sharedGraphStorageRejectsLazyDependency() { + assertMacroExpansion( + """ + @DependencyGraph + final class Graph { + @Provide(.shared) + private func makeRoot(value: Value) -> Root { Root() } + @Provide(.lazy) + private func makeValue() -> Value { Value() } + } + """, + expandedSource: """ + final class Graph { + private func makeRoot(value: Value) -> Root { Root() } + private func makeValue() -> Value { Value() } + } + """, + diagnostics: [ + DiagnosticSpec( + id: .init(domain: "Cradle", id: "invalidSharedLazyProviderReference"), + message: "shared 수명의 `@Provide` Factory는 `.lazy` 등록을 매개변수로 받을 수 없습니다.", + line: 4, + column: 31, + highlights: ["Value"] + ) + ], + macros: testMacros + ) +} + +// lazy Factory가 transient 결과를 보관하는지 원본 타입 위치에서 거부 확인 +@Test +func lazyGraphStorageRejectsTransientDependency() { + assertMacroExpansion( + """ + @DependencyGraph + final class Graph { + @Provide(.lazy) + private func makeRoot(value: Value) -> Root { Root() } + @Provide(.transient) + private func makeValue() -> Value { Value() } + } + """, + expandedSource: """ + final class Graph { + private func makeRoot(value: Value) -> Root { Root() } + private func makeValue() -> Value { Value() } + } + """, + diagnostics: [ + DiagnosticSpec( + id: .init(domain: "Cradle", id: "invalidLazyProviderReference"), + message: "lazy 수명의 `@Provide` Factory는 `.transient` 등록을 매개변수로 받을 수 없습니다.", + line: 4, + column: 31, + highlights: ["Value"] + ) + ], + macros: testMacros + ) +} + // shared 저장소 생성용 등록 descriptor 구성 private func sharedStorageProviders() throws -> [ProviderDescriptor] { let repositoryType = TypeSyntax("any Repository") diff --git a/Tests/CradleTestingXCTests/CradleTestingXCTestSupportTests.swift b/Tests/CradleTestingXCTests/CradleTestingXCTestSupportTests.swift index a7dd1b3..36ca418 100644 --- a/Tests/CradleTestingXCTests/CradleTestingXCTestSupportTests.swift +++ b/Tests/CradleTestingXCTests/CradleTestingXCTestSupportTests.swift @@ -43,4 +43,20 @@ final class CradleTestingXCTestSupportTests: XCTestCase { XCTAssertEqual(result.token, 5) } + + // XCTest에서 lazy mock Factory의 최초 접근 평가와 graph별 보관 확인 + func testLazyMockFactoryLifetime() { + var count = 0 + let graph = CradleTestingLifetimeGraph.override( + cradleTestingLazyService: .mock { + count += 1 + return CradleTestingLazyService(token: count) + } + ).build() + + XCTAssertEqual(count, 0) + XCTAssertEqual(graph.cradleTestingLazyService.token, 1) + XCTAssertEqual(count, 1) + XCTAssertTrue(graph.cradleTestingLazyService === graph.cradleTestingLazyService) + } } diff --git a/Tests/CradleTests/ActorGraphCompileFailureTests.swift b/Tests/CradleTests/ActorGraphCompileFailureTests.swift index 18973df..45e772e 100644 --- a/Tests/CradleTests/ActorGraphCompileFailureTests.swift +++ b/Tests/CradleTests/ActorGraphCompileFailureTests.swift @@ -29,8 +29,10 @@ func actorGraphCompileFailuresPreserveCompilerOwnership() throws { let nonSendableSource = nonSendableFixture .appendingPathComponent("Sources/ActorGraphNonSendableBoundary/main.swift") let isolationFixture = fixtures.appendingPathComponent("ActorGraphSharedIsolation") + let lazyNonSendableFixture = fixtures.appendingPathComponent("LazyActorGraphNonSendableBoundary") let nonSendableResult = try buildActorGraphFixture(at: nonSendableFixture) let isolationResult = try buildActorGraphFixture(at: isolationFixture) + let lazyNonSendableResult = try buildActorGraphFixture(at: lazyNonSendableFixture) let nonSendableError = "\(nonSendableSource.path):24:18: error: non-Sendable type " + "'ActorGraphNonSendableService' of property 'actorGraphNonSendableService' " + "cannot exit actor-isolated context" @@ -41,6 +43,11 @@ func actorGraphCompileFailuresPreserveCompilerOwnership() throws { #expect(isolationResult.terminationReason == .exit) #expect(isolationResult.status != 0) #expect(isolationResult.output.contains("instance member 'sequence' cannot be used on type")) + #expect(lazyNonSendableResult.terminationReason == .exit) + #expect(lazyNonSendableResult.status != 0) + #expect(lazyNonSendableResult.output.contains("non-Sendable type 'LazyActorNonSendableService'")) + #expect(lazyNonSendableResult.output.contains("cannot exit actor-isolated context")) + #expect(lazyNonSendableResult.output.contains("actor-isolated property 'lazyActorNonSendableService'")) } // fixture 실행 없이 별도 scratch 경로에서 엄격한 동시성 Swift build와 진단 수집 diff --git a/Tests/CradleTests/CradlePluginIntegrationFixtureTests.swift b/Tests/CradleTests/CradlePluginIntegrationFixtureTests.swift index b50b67b..9600c98 100644 --- a/Tests/CradleTests/CradlePluginIntegrationFixtureTests.swift +++ b/Tests/CradleTests/CradlePluginIntegrationFixtureTests.swift @@ -41,6 +41,10 @@ func cradlePluginConsumerBuildCreatesMermaidOutputOutsideTargetSources() throws $0.contains("AppGraph") && $0.contains("graph0_provider0 --> graph0_provider1") }) #expect(result.diagramContents.contains { $0.contains("class graph0_provider0 transient") }) + #expect(result.diagramContents.contains { $0.contains("LazyGraph") && $0.contains(".lazy") }) + #expect(result.diagramContents.contains { + $0.contains("classDef lazy stroke:#333,stroke-width:2px,stroke-dasharray:2 3;") + }) #expect(result.diagramContents.contains { $0.contains("ExplicitGraph") }) #expect(result.diagramContents.contains { $0.contains("ExternalGraph") && !$0.contains("ExternalFeature") }) #expect(result.diagramContents.contains { $0.contains("Composition.NestedGraph") }) diff --git a/Tests/CradleTests/CradleTestingSwiftTestingSupportTests.swift b/Tests/CradleTests/CradleTestingSwiftTestingSupportTests.swift index 91a0f57..d94be2d 100644 --- a/Tests/CradleTests/CradleTestingSwiftTestingSupportTests.swift +++ b/Tests/CradleTests/CradleTestingSwiftTestingSupportTests.swift @@ -48,6 +48,8 @@ func externalProviderOverrideMockSupportsExternalInput() { private final class CradleTestingMockFactoryProbe { // shared mock Factory 호출 횟수 var sharedCount = 0 + // lazy mock Factory 호출 횟수 + var lazyCount = 0 // transient mock Factory 호출 횟수 var transientCount = 0 } @@ -61,6 +63,10 @@ func cradleTestingSwiftTestingPreservesMockFactoryLifetimes() { probe.sharedCount += 1 return CradleTestingSharedService(token: probe.sharedCount) }, + cradleTestingLazyService: .mock { + probe.lazyCount += 1 + return CradleTestingLazyService(token: probe.lazyCount) + }, cradleTestingTransientService: .mock { shared in probe.transientCount += 1 return CradleTestingTransientService(shared: shared) @@ -68,17 +74,24 @@ func cradleTestingSwiftTestingPreservesMockFactoryLifetimes() { ) #expect(probe.sharedCount == 0) + #expect(probe.lazyCount == 0) #expect(probe.transientCount == 0) let first = builder.build() let second = builder.build() #expect(probe.sharedCount == 2) + #expect(probe.lazyCount == 0) #expect(probe.transientCount == 0) #expect(first.cradleTestingSharedService !== second.cradleTestingSharedService) + let firstLazy = first.cradleTestingLazyService + let secondLazy = first.cradleTestingLazyService let firstTransient = first.cradleTestingTransientService let secondTransient = first.cradleTestingTransientService + #expect(probe.lazyCount == 1) #expect(probe.transientCount == 2) + #expect(firstLazy === secondLazy) + #expect(firstLazy.token == 1) #expect(firstTransient.shared === first.cradleTestingSharedService) #expect(secondTransient.shared === first.cradleTestingSharedService) } diff --git a/Tests/CradleTests/DependencyGraphAccessControlTests.swift b/Tests/CradleTests/DependencyGraphAccessControlTests.swift index 720a689..5dfc4be 100644 --- a/Tests/CradleTests/DependencyGraphAccessControlTests.swift +++ b/Tests/CradleTests/DependencyGraphAccessControlTests.swift @@ -18,6 +18,17 @@ func anotherModuleCanUsePublicGraphAccessor() { _ = graph.publicService } +// 별도 module의 public lazy graph 접근자 호출 확인 +@Test +func anotherModuleCanUsePublicLazyGraphAccessor() { + let graph = PublicLazyGraph() + let first = graph.publicLazyService + let second = graph.publicLazyService + + #expect(first.token == 41) + #expect(first === second) +} + // 별도 module의 public `@External` 생성 메서드 호출 확인 @Test func externalProviderMethodIsAvailableFromAnotherModule() { diff --git a/Tests/CradleTests/LazyDependencyGraphTests.swift b/Tests/CradleTests/LazyDependencyGraphTests.swift new file mode 100644 index 0000000..512add6 --- /dev/null +++ b/Tests/CradleTests/LazyDependencyGraphTests.swift @@ -0,0 +1,365 @@ +// +// LazyDependencyGraphTests.swift +// CradleTests +// +// Created by opfic on 9/6/26. +// + +import Cradle +import Testing + +// lazy Factory의 실행 횟수를 기록할 graph별 probe +final class LazyFactoryProbe { + // 생성한 leaf 수 + var leafCount = 0 + // 생성한 branch 수 + var branchCount = 0 + // 생성한 미사용 값 수 + var unusedCount = 0 +} + +// graph가 lazy로 보관할 leaf 참조 값 +final class LazyLeaf { + // 생성 순번 + let sequence: Int + + // 생성 순번 보관 + init(sequence: Int) { + self.sequence = sequence + } +} + +// lazy leaf를 요구하는 lazy branch 값 +final class LazyBranch { + // 주입한 leaf + let leaf: LazyLeaf + + // leaf 보관 + init(leaf: LazyLeaf) { + self.leaf = leaf + } +} + +// lazy diamond의 공용 leaf를 요구하는 첫 번째 분기 +final class LazyFirstDiamondBranch { + // 공용 leaf 보관 + let leaf: LazyLeaf + + // leaf 보관 + init(leaf: LazyLeaf) { + self.leaf = leaf + } +} + +// lazy diamond의 공용 leaf를 요구하는 두 번째 분기 +final class LazySecondDiamondBranch { + // 공용 leaf 보관 + let leaf: LazyLeaf + + // leaf 보관 + init(leaf: LazyLeaf) { + self.leaf = leaf + } +} + +// 두 lazy 분기를 요구하는 diamond 결과 +final class LazyDiamondRoot { + // 첫 번째 분기 보관 + let first: LazyFirstDiamondBranch + // 두 번째 분기 보관 + let second: LazySecondDiamondBranch + + // 두 분기 보관 + init(first: LazyFirstDiamondBranch, second: LazySecondDiamondBranch) { + self.first = first + self.second = second + } +} + +// graph 상태를 lazy Factory가 읽는지 확인할 값 +final class LazyStateValue { + // 최초 접근 시 읽은 상태 + let value: Int + + // 상태 값 보관 + init(value: Int) { + self.value = value + } +} + +// graph 상태와 등록별 lazy 평가를 확인할 graph +@DependencyGraph +final class LazyDependencyGraph { + // Factory 실행 횟수 기록 + private let probe: LazyFactoryProbe + // 최초 접근 전 변경 가능한 graph 상태 + private var state = 0 + + // graph 상태와 probe를 주입한 graph 생성 + init(probe: LazyFactoryProbe) { + self.probe = probe + } + + // graph 상태 갱신 + func updateState(to value: Int) { + state = value + } + + // 최초 접근에 leaf 생성 + @Provide(.lazy) + private func makeLazyLeaf() -> LazyLeaf { + probe.leafCount += 1 + return LazyLeaf(sequence: probe.leafCount) + } + + // lazy leaf를 연결해 최초 접근에 branch 생성 + @Provide(.lazy) + private func makeLazyBranch(leaf: LazyLeaf) -> LazyBranch { + probe.branchCount += 1 + return LazyBranch(leaf: leaf) + } + + // 접근하지 않은 Factory의 지연 생성 확인 + @Provide(.lazy) + private func makeUnusedLazyValue() -> String { + probe.unusedCount += 1 + return "unused" + } + + // Factory 실행 시점의 graph 상태 생성 + @Provide(.lazy) + private func makeLazyStateValue() -> LazyStateValue { + LazyStateValue(value: state) + } +} + +// lazy 결과를 교체할 override graph 값 +final class LazyOverrideService { + // 교체 여부 확인 값 + let value: Int + + // 교체 결과 보관 + init(value: Int) { + self.value = value + } +} + +// lazy 교체 Factory의 지연 실행 확인용 graph +@DependencyGraph(overrides: true) +final class LazyOverrideGraph { + // 원본 또는 교체 Factory를 lazy로 평가 + @Provide(.lazy) + private func makeLazyOverrideService() -> LazyOverrideService { + LazyOverrideService(value: 1) + } +} + +// 원본 lazy override Factory 호출 횟수를 확인할 결과 +final class LazyOverrideOriginalService { + // 원본 Factory의 생성 순번 + let count: Int + + // 생성 순번 보관 + init(count: Int) { + self.count = count + } +} + +// 원본 lazy override Factory가 `selection` 이름을 써도 되는지 확인할 graph +@DependencyGraph(overrides: true) +final class LazyOverrideOriginalGraph { + // 원본 Factory 실행 횟수 + private var count = 0 + + // generated helper의 지역 이름과 같은 원본 Factory + @Provide(.lazy) + private func selection() -> LazyOverrideOriginalService { + count += 1 + return LazyOverrideOriginalService(count: count) + } + + // 원본 Factory 실행 횟수 반환 + func factoryCount() -> Int { + count + } +} + +// `preconditionFailure` 이름 충돌을 확인할 lazy override 결과 +final class LazyOverridePreconditionFailureService {} + +// 표준 함수 이름과 같은 원본 Factory를 포함한 lazy override graph +@DependencyGraph(overrides: true) +final class LazyOverridePreconditionFailureGraph { + // generated helper의 실패 경로와 같은 이름의 원본 Factory + @Provide(.lazy) + private func preconditionFailure() -> LazyOverridePreconditionFailureService { + LazyOverridePreconditionFailureService() + } +} + +// lazy diamond의 동일 leaf를 graph별로 보관할 graph +@DependencyGraph +final class LazyDiamondGraph { + // 공용 leaf 생성 + @Provide(.lazy) + private func makeLazyLeaf() -> LazyLeaf { + LazyLeaf(sequence: 1) + } + + // 첫 번째 diamond 분기 생성 + @Provide(.lazy) + private func makeLazyFirstDiamondBranch(leaf: LazyLeaf) -> LazyFirstDiamondBranch { + LazyFirstDiamondBranch(leaf: leaf) + } + + // 두 번째 diamond 분기 생성 + @Provide(.lazy) + private func makeLazySecondDiamondBranch(leaf: LazyLeaf) -> LazySecondDiamondBranch { + LazySecondDiamondBranch(leaf: leaf) + } + + // 두 분기를 연결한 diamond 결과 생성 + @Provide(.lazy) + private func makeLazyDiamondRoot( + first: LazyFirstDiamondBranch, + second: LazySecondDiamondBranch + ) -> LazyDiamondRoot { + LazyDiamondRoot(first: first, second: second) + } +} + +// graph 생성과 미사용 등록이 lazy Factory를 실행하지 않는지 확인 +@Test +func lazyDependencyGraphDefersUnusedFactoriesUntilAccess() { + let probe = LazyFactoryProbe() + let graph = LazyDependencyGraph(probe: probe) + + #expect(probe.leafCount == 0) + #expect(probe.branchCount == 0) + #expect(probe.unusedCount == 0) + + let branch = graph.lazyBranch + + #expect(probe.leafCount == 1) + #expect(probe.branchCount == 1) + #expect(probe.unusedCount == 0) + #expect(branch === graph.lazyBranch) + #expect(branch.leaf === graph.lazyLeaf) +} + +// lazy Factory가 graph 생성 뒤 변경한 상태를 최초 접근에 읽는지 확인 +@Test +func lazyDependencyGraphReadsGraphStateAtFirstAccess() { + let graph = LazyDependencyGraph(probe: LazyFactoryProbe()) + graph.updateState(to: 8) + + #expect(graph.lazyStateValue.value == 8) +} + +// graph마다 lazy 결과를 독립적으로 보관하는지 확인 +@Test +func lazyDependencyGraphSeparatesGraphInstances() { + let first = LazyDependencyGraph(probe: LazyFactoryProbe()) + let second = LazyDependencyGraph(probe: LazyFactoryProbe()) + + #expect(first.lazyLeaf !== second.lazyLeaf) + #expect(first.lazyLeaf.sequence == 1) + #expect(second.lazyLeaf.sequence == 1) +} + +// lazy diamond이 공용 의존성을 graph 안에서 한 번만 연결하는지 확인 +@Test +func lazyDependencyGraphReusesDiamondLeaf() { + let graph = LazyDiamondGraph() + let root = graph.lazyDiamondRoot + + #expect(root.first.leaf === root.second.leaf) + #expect(root.first.leaf === graph.lazyLeaf) +} + +// override builder와 build가 lazy 교체 Factory를 실행하지 않는지 확인 +@Test +func lazyOverrideGraphDefersReplacementUntilAccess() { + var count = 0 + let builder = LazyOverrideGraph.override( + lazyOverrideService: .replace { + count += 1 + return LazyOverrideService(value: 9) + } + ) + + #expect(count == 0) + let graph = builder.build() + #expect(count == 0) + + #expect(graph.lazyOverrideService.value == 9) + #expect(count == 1) + #expect(graph.lazyOverrideService.value == 9) + #expect(count == 1) +} + +// 원본 lazy override Factory가 build 뒤 첫 접근에서 한 번만 실행되는지 확인 +@Test +func lazyOverrideGraphDefersOriginalFactoryUntilAccess() { + let graph = LazyOverrideOriginalGraph.override().build() + + #expect(graph.factoryCount() == 0) + #expect(graph.lazyOverrideOriginalService.count == 1) + #expect(graph.factoryCount() == 1) + #expect(graph.lazyOverrideOriginalService.count == 1) + #expect(graph.factoryCount() == 1) +} + +// 표준 함수 이름과 같은 lazy Factory도 original 경로에서 호출하는지 확인 +@Test +func lazyOverrideGraphSupportsPreconditionFailureFactoryName() { + let graph = LazyOverridePreconditionFailureGraph.override().build() + + #expect( + graph.lazyOverridePreconditionFailureService + === graph.lazyOverridePreconditionFailureService + ) +} + +// lazy 교체 closure의 해제 시점을 확인할 참조 값 +private final class LazyOverrideCapture {} + +// 최초 평가 뒤 graph가 lazy 교체 closure를 보관하지 않는지 확인 +@Test +func lazyOverrideGraphReleasesReplacementAfterFirstAccess() { + weak var observed: LazyOverrideCapture? + let graph: LazyOverrideGraph + + do { + let capture = LazyOverrideCapture() + observed = capture + graph = LazyOverrideGraph.override( + lazyOverrideService: .replace { + withExtendedLifetime(capture) { + LazyOverrideService(value: 10) + } + } + ).build() + } + + #expect(observed != nil) + #expect(graph.lazyOverrideService.value == 10) + #expect(observed == nil) +} + +// graph 해제 뒤 lazy 저장소가 보관한 결과도 함께 해제하는지 확인 +@Test +func lazyDependencyGraphReleasesStoredResult() { + weak var observed: LazyLeaf? + + do { + let graph = LazyDependencyGraph(probe: LazyFactoryProbe()) + observed = graph.lazyLeaf + + withExtendedLifetime(graph) { + #expect(observed != nil) + } + } + + #expect(observed == nil) +} diff --git a/Tests/CradleTests/LazyProviderCompatibilityTests.swift b/Tests/CradleTests/LazyProviderCompatibilityTests.swift new file mode 100644 index 0000000..9219761 --- /dev/null +++ b/Tests/CradleTests/LazyProviderCompatibilityTests.swift @@ -0,0 +1,248 @@ +// +// LazyProviderCompatibilityTests.swift +// CradleTests +// +// Created by opfic on 9/6/26. +// + +import Cradle +import Testing + +// source graph가 매번 생성할 lazy 입력 값 +struct LazySourceInput { + // source graph 생성 순번 + let sequence: Int +} + +// lazy source 조합의 최초 접근 시점을 기록할 source graph +@DependencyGraph +final class LazySourceGraph { + // source Factory 실행 횟수 + private(set) var count = 0 + + // source graph가 매번 생성할 입력 값 + @Provide(.transient) + private func makeLazySourceInput() -> LazySourceInput { + count += 1 + return LazySourceInput(sequence: count) + } +} + +// source graph 결과를 lazy로 보관할 조합 graph +@DependencyGraph(sources: [LazySourceGraph.self]) +final class LazySourceFeatureGraph { + // source graph의 transient 결과를 최초 접근에 읽는 lazy Factory + @Provide(.lazy) + private func makeLazySourceInput() -> LazySourceInput { + lazySourceGraph.lazySourceInput + } +} + +// source graph 결과를 override 가능한 lazy로 보관할 조합 graph +@DependencyGraph(sources: [LazySourceGraph.self], overrides: true) +final class LazyOverrideSourceFeatureGraph { + // source graph의 transient 결과를 최초 접근에 읽는 원본 lazy Factory + @Provide(.lazy) + private func makeLazySourceInput() -> LazySourceInput { + lazySourceGraph.lazySourceInput + } +} + +// lazy 반환 타입의 protocol 계약 +protocol LazyRepository {} + +// lazy protocol 반환 타입의 구현 +final class LazyLiveRepository: LazyRepository {} + +// lazy 반환 타입의 superclass 계약 +class LazyRepositoryBase {} + +// lazy superclass 반환 타입의 구현 +final class LazyRepositorySubclass: LazyRepositoryBase {} + +// bodyless lazy Factory가 생성할 concrete 값 +final class LazyBodylessConfiguration {} + +// lazy Factory의 원래 `#function` 문맥 확인용 graph +@DependencyGraph +final class LazyFunctionGraph { + // 원래 Factory 문맥을 반환할 lazy 등록 + @Provide(.lazy) + private func makeLazyFunctionIdentifier() -> String { + #function + } +} + +// protocol·superclass·bodyless lazy Factory를 함께 확인할 graph +@DependencyGraph +final class LazyReturnTypeGraph { + // protocol로 노출할 lazy 구현 생성 + @Provide(.lazy) + private func makeLazyRepository() -> any LazyRepository { + LazyLiveRepository() + } + + // superclass로 노출할 lazy 구현 생성 + @Provide(.lazy) + private func makeLazyRepositoryBase() -> LazyRepositoryBase { + LazyRepositorySubclass() + } + + // 기본 initializer를 사용할 bodyless lazy Factory + @Provide(.lazy) + private func makeLazyBodylessConfiguration() -> LazyBodylessConfiguration +} + +// actor graph가 lazy로 보관할 Sendable 참조 값 +final class LazyActorService: Sendable { + // actor 내부에서 기록한 생성 순번 + let sequence: Int + + // 생성 순번 보관 + init(sequence: Int) { + self.sequence = sequence + } +} + +// actor 격리 안에서 lazy Factory를 평가할 graph +@DependencyGraph +actor LazyActorGraph { + // actor 격리 생성 순번 + private var count = 0 + + // actor 상태를 최초 접근에 읽는 lazy Factory + @Provide(.lazy) + private func makeLazyActorService() -> LazyActorService { + count += 1 + return LazyActorService(sequence: count) + } +} + +// actor 격리 안에서 override 가능한 lazy Factory를 평가할 graph +@DependencyGraph(overrides: true) +actor LazyOverrideActorGraph { + // 원본 lazy actor 결과 생성 + @Provide(.lazy) + private func makeLazyActorService() -> LazyActorService { + LazyActorService(sequence: 2) + } +} + +// MainActor graph가 lazy로 보관할 참조 값 +final class LazyMainActorService {} + +// MainActor 격리 안에서 lazy Factory를 평가할 graph +@MainActor +@DependencyGraph +final class LazyMainActorGraph { + // MainActor Factory 실행 횟수 + private var count = 0 + + // MainActor 상태를 최초 접근에 읽는 lazy Factory + @Provide(.lazy) + private func makeLazyMainActorService() -> LazyMainActorService { + count += 1 + return LazyMainActorService() + } +} + +// MainActor 격리 안에서 override 가능한 lazy Factory를 평가할 graph +@MainActor +@DependencyGraph(overrides: true) +final class LazyOverrideMainActorGraph { + // 원본 lazy MainActor 결과 생성 + @Provide(.lazy) + private func makeLazyMainActorService() -> LazyMainActorService { + LazyMainActorService() + } +} + +// lazy Factory가 source graph의 transient 결과를 최초 접근까지 미루는지 확인 +@Test +func lazyProviderDefersSourceGraphAccessUntilFirstAccess() { + let source = LazySourceGraph() + let graph = LazySourceFeatureGraph(lazySourceGraph: source) + + #expect(source.count == 0) + #expect(graph.lazySourceInput.sequence == 1) + #expect(source.count == 1) + #expect(graph.lazySourceInput.sequence == 1) + #expect(source.count == 1) +} + +// override source graph도 lazy 원본 Factory에서 최초 접근까지 읽지 않는지 확인 +@Test +func lazyOverrideProviderDefersSourceGraphAccessUntilFirstAccess() { + let source = LazySourceGraph() + let graph = LazyOverrideSourceFeatureGraph.override().build(lazySourceGraph: source) + + #expect(source.count == 0) + #expect(graph.lazySourceInput.sequence == 1) + #expect(source.count == 1) +} + +// lazy Factory가 원래 `#function` 문맥과 반환 타입을 보존하는지 확인 +@Test +func lazyProviderPreservesFunctionContextAndReturnTypes() throws { + #expect(LazyFunctionGraph().string == "makeLazyFunctionIdentifier()") + + let graph = LazyReturnTypeGraph() + let repository = try #require(graph.lazyRepository as? LazyLiveRepository) + let base = try #require(graph.lazyRepositoryBase as? LazyRepositorySubclass) + + #expect(repository === graph.lazyRepository as? LazyLiveRepository) + #expect(base === graph.lazyRepositoryBase as? LazyRepositorySubclass) + #expect(graph.lazyBodylessConfiguration === graph.lazyBodylessConfiguration) +} + +// actor graph가 lazy 결과를 actor 격리 안에서 한 번만 만드는지 확인 +@Test +func lazyProviderPreservesActorIsolation() async { + let graph = LazyActorGraph() + let first = await graph.lazyActorService + let second = await graph.lazyActorService + + #expect(first === second) + #expect(first.sequence == 1) +} + +// actor override graph가 lazy 교체 Factory를 actor 격리 안에서 한 번만 평가하는지 확인 +@Test +func lazyOverrideProviderPreservesActorIsolation() async { + let graph = LazyOverrideActorGraph.override( + lazyActorService: .replace { + LazyActorService(sequence: 9) + } + ).build() + let first = await graph.lazyActorService + let second = await graph.lazyActorService + + #expect(first === second) + #expect(first.sequence == 9) +} + +// MainActor graph가 lazy 결과를 MainActor 안에서 한 번만 만드는지 확인 +@Test +@MainActor +func lazyProviderPreservesMainActorIsolation() { + let graph = LazyMainActorGraph() + let first = graph.lazyMainActorService + let second = graph.lazyMainActorService + + #expect(first === second) +} + +// MainActor override graph가 lazy 교체 Factory를 한 번만 평가하는지 확인 +@Test +@MainActor +func lazyOverrideProviderPreservesMainActorIsolation() { + let graph = LazyOverrideMainActorGraph.override( + lazyMainActorService: .replace { + LazyMainActorService() + } + ).build() + let first = graph.lazyMainActorService + let second = graph.lazyMainActorService + + #expect(first === second) +} diff --git a/Tests/CradleTests/LazyProviderCompileFailureTests.swift b/Tests/CradleTests/LazyProviderCompileFailureTests.swift new file mode 100644 index 0000000..24dff67 --- /dev/null +++ b/Tests/CradleTests/LazyProviderCompileFailureTests.swift @@ -0,0 +1,96 @@ +// +// LazyProviderCompileFailureTests.swift +// CradleTests +// +// Created by opfic on 9/6/26. +// + +import Foundation +import Testing + +// lazy provider compiler fixture의 종료 상태와 진단 출력 +private struct LazyProviderCompileResult { + // 정상 종료와 신호 종료 구분 + let terminationReason: Process.TerminationReason + // Swift build 종료 코드 + let status: Int32 + // compiler 표준 출력과 오류 출력 + let output: String +} + +// lazy 수명 연결과 외부 입력 제한이 원본 위치에서 거부되는지 확인 +@Test +func lazyProviderCompileFailuresReportOriginalLocations() throws { + let fixture = lazyProviderFixture(named: "LazyProviderInvalidUsage") + let source = fixture.appendingPathComponent("Sources/LazyProviderInvalidUsage/main.swift") + let result = try buildLazyProviderFixture(at: fixture) + let errors = [ + "\(source.path):18:9: error: shared 수명의 `@Provide` Factory는 `.lazy` 등록을 매개변수로 받을 수 없습니다.", + "\(source.path):34:9: error: lazy 수명의 `@Provide` Factory는 `.transient` 등록을 매개변수로 받을 수 없습니다.", + "\(source.path):45:3: error: `@External`은 명시적인 `@Provide(.transient)`에서만 사용할 수 있습니다.", + "순환 의존성이 있습니다." + ] + + #expect(result.terminationReason == .exit) + #expect(result.status != 0) + for error in errors { + #expect(result.output.contains(error)) + } +} + +// Unicode 반환 타입의 lazy 저장소 이름을 서로 구분하는지 확인 +@Test +func lazyProviderCompilesDistinctUnicodeStorageNames() throws { + let result = try buildLazyProviderFixture( + at: lazyProviderFixture(named: "LazyProviderUnicodeStorage") + ) + + #expect(result.terminationReason == .exit) + #expect(result.status == 0, Comment(rawValue: result.output)) +} + +// 이름으로 선택한 lazy provider compiler fixture 경로 +private func lazyProviderFixture(named name: String) -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("CompileFixtures") + .appendingPathComponent(name) +} + +// fixture 실행 없이 별도 scratch 경로에서 compiler 진단 수집 +private func buildLazyProviderFixture(at fixture: URL) throws -> LazyProviderCompileResult { + let temporary = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let scratch = temporary.appendingPathComponent("scratch") + let outputFile = temporary.appendingPathComponent("compiler-output.log") + try FileManager.default.createDirectory(at: temporary, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: temporary) + } + guard FileManager.default.createFile(atPath: outputFile.path, contents: nil) else { + throw CocoaError(.fileWriteUnknown) + } + let handle = try FileHandle(forWritingTo: outputFile) + defer { + try? handle.close() + } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [ + "swift", "build", + "--package-path", fixture.path, + "--scratch-path", scratch.path, + "-Xswiftc", "-diagnostic-style", "-Xswiftc", "llvm" + ] + process.standardOutput = handle + process.standardError = handle + try process.run() + process.waitUntilExit() + try handle.close() + let output = String(data: try Data(contentsOf: outputFile), encoding: .utf8) ?? "" + return LazyProviderCompileResult( + terminationReason: process.terminationReason, + status: process.terminationStatus, + output: output + ) +} diff --git a/Tests/IntegrationFixtures/CradlePluginConsumer/Sources/AppComposition/AppGraph.swift b/Tests/IntegrationFixtures/CradlePluginConsumer/Sources/AppComposition/AppGraph.swift index b892a52..394e1fb 100644 --- a/Tests/IntegrationFixtures/CradlePluginConsumer/Sources/AppComposition/AppGraph.swift +++ b/Tests/IntegrationFixtures/CradlePluginConsumer/Sources/AppComposition/AppGraph.swift @@ -13,6 +13,14 @@ final class AppGraph { } } +@DependencyGraph +final class LazyGraph { + @Provide(.lazy) + private func makeLazyFeature() -> LazyFeature { + LazyFeature() + } +} + @DependencyGraph(diagram: false) final class ExcludedGraph { @Provide @@ -72,6 +80,8 @@ struct Feature { let repository: Repository } +struct LazyFeature {} + struct ExcludedFeature {} struct ExplicitFeature {} diff --git a/docs/Architecture.md b/docs/Architecture.md index 247b43b..0edf17d 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -1,6 +1,6 @@ # Cradle 아키텍처 -Cradle은 compiler가 graph 선언을 확장하는 경로와 build 중 Mermaid를 만드는 경로를 나눠요. 둘 다 소비자 Swift source를 보지만, 서로의 결과를 사용하지 않아요. +Cradle은 compiler가 graph 선언을 확장하는 경로와 build 중 Mermaid를 만드는 경로를 나눠요. 둘 다 소비자 Swift source를 보지만 서로의 결과를 사용하지 않아요. ## 소비자 build 흐름 @@ -36,13 +36,13 @@ flowchart LR Maker -.->|개발용 .mmd 생성| Output["plugin work directory
CradleDiagrams/module/DependencyGraph.mmd
binary 미포함"] ``` -소비자 target은 `Cradle`만 import해요. `CradleMacros`는 `Cradle`이 참조하는 Macro 구현 target이므로 소비자가 직접 의존하거나 import하지 않아요. Macro는 `@DependencyGraph`와 `@Provide`를 확장하고, graph 연결이 성립하지 않는 경우 compiler diagnostic을 만들어요. +소비자 target은 `Cradle`만 import해요. `CradleMacros`는 `Cradle`이 참조하는 Macro 구현 target이므로 소비자가 직접 의존하거나 import하지 않아요. Macro는 `@DependencyGraph`와 `@Provide`를 확장하고 graph 연결이 성립하지 않는 경우 compiler diagnostic을 만들어요. -`CradleTesting`은 test target에서만 선택해요. `CradlePlugin`도 별도 경로예요. plugin은 현재 target의 Swift source를 `CradleDiagramMaker`에 전달해 `.mmd`를 만들 뿐, app이나 library binary에 연결하지 않아요. +`CradleTesting`은 test target에서만 선택해요. `CradlePlugin`도 별도 경로예요. plugin은 현재 target의 Swift source를 `CradleDiagramMaker`에 전달해 `.mmd`를 만들 뿐 app이나 library binary에 연결하지 않아요. ## artifact 제작 경로 -소비자 build는 prebuilt `CradleDiagramMaker`를 사용해요. 아래 target들은 그 artifact를 제작하고 검증할 때만 쓰며, 소비자 target의 의존성이 아니에요. +소비자 build는 prebuilt `CradleDiagramMaker`를 사용해요. 아래 target들은 그 artifact를 제작하고 검증할 때만 쓰며 소비자 target의 의존성이 아니에요. ```mermaid flowchart LR