Skip to content

Commit 9f693ef

Browse files
committed
feat: prepare for open source release
- add MIT license - rewrite README with problem statement, usage docs, and architecture overview - parameterize hardcoded paths in Ruby wrapper via environment variables - add test suite for declaration extraction, interface rewriting, project rewriting, and string utilities - add GitHub Actions CI workflow - add test target to Package.swift
1 parent 9ac86a2 commit 9f693ef

9 files changed

Lines changed: 533 additions & 14 deletions

File tree

.github/workflows/test.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: macos-14
12+
steps:
13+
- uses: actions/checkout@v4
14+
- name: Build
15+
run: swift build
16+
- name: Test
17+
run: swift test

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2024 Kaan Biryol
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Package.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ let package = Package(
1919
.product(name: "SwiftSyntax", package: "swift-syntax"),
2020
.product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
2121
.product(name: "SourceKittenFramework", package: "SourceKitten"),
22+
]),
23+
.testTarget(
24+
name: "GenerateInterfaceTests",
25+
dependencies: [
26+
"generateInterface",
27+
.product(name: "SwiftSyntax", package: "swift-syntax"),
28+
.product(name: "SwiftParser", package: "swift-syntax"),
2229
])
2330
]
2431
)

README.md

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,98 @@
11
# GenerateInterface
2-
3-
- `make build`
42

3+
A command-line tool that automatically generates Swift interface modules from compiled module interfaces using SourceKit and SwiftSyntax.
4+
5+
## The Problem
6+
7+
In large modular iOS codebases, modules often depend on each other's full implementations even when they only need access to the public API. This means changing an implementation detail in one module can trigger recompilation of all dependent modules, slowing down builds significantly.
8+
9+
**Interface modules** solve this by extracting a module's public API surface into a separate, lightweight module. Dependents import the interface module instead of the full implementation, so implementation changes no longer cascade rebuilds across the dependency graph.
10+
11+
Creating and maintaining these interface modules by hand is tedious and error-prone. This tool automates the process.
12+
13+
## What It Does
14+
15+
Given a module name and its compiler arguments, the tool:
16+
17+
1. **Extracts the module interface** via SourceKit (the same engine Xcode uses for code completion and indexing)
18+
2. **Rewrites the interface** using SwiftSyntax - strips private/internal imports (prefixed with `_`), removes `some`/`any` type erasure wrappers, simplifies member type syntax, filters out builder classes, and merges duplicate extensions
19+
3. **Replaces declarations** with their original source versions when available (preserving doc comments, attributes, and formatting)
20+
4. **Creates the interface module directory** with `Sources/` and `TestSupport/` folders
21+
5. **Rewrites import statements** across the codebase (`import Module` -> `import ModuleInterface`)
22+
6. **Updates Project.swift** to register the new interface module with the correct dependencies (Tuist-specific)
23+
24+
## Requirements
25+
26+
- macOS 13+
27+
- Xcode (for SourceKit)
28+
- Swift 5.10+
29+
30+
## Installation
31+
32+
```bash
33+
# Build from source
34+
swift build -c release
35+
36+
# Or use the Makefile (builds for arm64)
37+
make build
538
```
39+
40+
## Usage
41+
42+
### Direct invocation
43+
44+
```bash
645
./generateInterface \
7-
"PROJECT.SWIFT_FILE_PATH" \
8-
"MODULE_NAME" \
9-
"PATH_TO_MODULES" \
10-
--target "arm64-apple-ios16.0.0" \
11-
--sdk "/Applications/Xcode-15.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS17.5.sdk" \
12-
--build-dir "/Users/USER/Library/Developer/Xcode/DerivedData/App-xxx/Build/Products/Debug-iphoneos"
46+
"/path/to/Project.swift" \
47+
"MyModule" \
48+
"/path/to/modules" \
49+
"/path/to/compiler-args.txt"
50+
```
51+
52+
**Arguments:**
53+
- `projectSwiftPath` - path to the Tuist `Project.swift` file
54+
- `moduleName` - name of the module to generate an interface for
55+
- `modulesPath` - root directory containing all modules
56+
- `compilerArgsPath` - path to a file containing Swift compiler arguments (one per line)
57+
58+
**Flags:**
59+
- `--print-only` - preview the generated interface without writing any files
60+
61+
### With the Ruby wrapper
62+
63+
The included `generateInterface.rb` script automates compiler argument extraction from Xcode build settings:
64+
65+
```bash
66+
ruby generateInterface.rb MyModule
67+
ruby generateInterface.rb MyModule --print-only
68+
```
69+
70+
The wrapper:
71+
1. Runs `xcodebuild -showBuildSettingsForIndex` for the module's scheme
72+
2. Extracts Swift compiler arguments from the build settings
73+
3. Caches build settings to avoid repeated Xcode calls
74+
4. Invokes the Swift tool with the extracted arguments
75+
76+
Configure the wrapper by setting these environment variables:
77+
- `WORKSPACE` - Xcode workspace name (default: `App.xcworkspace`)
78+
- `TOOL_PATH` - path to the built `generateInterface` binary (default: `tools/generateInterface`)
79+
- `MODULES_PATH` - path to the modules directory (default: `libraries`)
80+
81+
## How It Works
82+
83+
The core pipeline uses two Apple frameworks:
84+
85+
- **SourceKittenFramework** - sends a `source.request.editor.open.interface` request to Xcode's SourceKit daemon, which returns the full public interface of a compiled module (the same text you see in Xcode's "Generated Interface" view)
86+
- **SwiftSyntax** - parses and rewrites the generated interface at the AST level, ensuring transformations are structurally correct rather than fragile string replacements
87+
88+
The `ProjectRewriter` manipulates Tuist's `Module(...)` declarations via SwiftSyntax to add the new interface module definition, update dependency lists, and wire up test support targets.
89+
90+
## Limitations
1391

14-
complierArgsFilePath instead of target, sdk and build-dir as an argument
92+
- The `Project.swift` rewriting is specific to a Tuist project structure using `Module(...)` declarations with `kind`, `moduleDependencies`, and `features` parameters. You may need to adapt `ProjectRewriter.swift` for your project's conventions.
93+
- The Ruby wrapper assumes an Xcode workspace-based project. Adjust if using a different build system.
94+
- Builder class filtering (`classes inheriting from Builder are excluded`) is project-specific behavior.
1595

16-
```
96+
## License
1797

98+
MIT - see [LICENSE](LICENSE).
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import XCTest
2+
import SwiftSyntax
3+
import SwiftParser
4+
@testable import generateInterface
5+
6+
final class DeclarationExtractorTests: XCTestCase {
7+
func testExtractsStructDeclarations() {
8+
let source = """
9+
public struct MyModel {
10+
let name: String
11+
}
12+
"""
13+
let declarations = extractDeclarations(from: source)
14+
XCTAssertEqual(declarations.count, 1)
15+
XCTAssertTrue(declarations[0].is(StructDeclSyntax.self))
16+
}
17+
18+
func testExtractsClassDeclarations() {
19+
let source = """
20+
public class MyService {
21+
func doWork() {}
22+
}
23+
"""
24+
let declarations = extractDeclarations(from: source)
25+
XCTAssertEqual(declarations.count, 1)
26+
XCTAssertTrue(declarations[0].is(ClassDeclSyntax.self))
27+
}
28+
29+
func testExtractsProtocolDeclarations() {
30+
let source = """
31+
public protocol MyProtocol {
32+
func execute()
33+
}
34+
"""
35+
let declarations = extractDeclarations(from: source)
36+
XCTAssertEqual(declarations.count, 1)
37+
XCTAssertTrue(declarations[0].is(ProtocolDeclSyntax.self))
38+
}
39+
40+
func testExtractsEnumDeclarations() {
41+
let source = """
42+
public enum State {
43+
case active
44+
case inactive
45+
}
46+
"""
47+
let declarations = extractDeclarations(from: source)
48+
XCTAssertEqual(declarations.count, 1)
49+
XCTAssertTrue(declarations[0].is(EnumDeclSyntax.self))
50+
}
51+
52+
func testExtractsTypealiasDeclarations() {
53+
let source = """
54+
public typealias Handler = (String) -> Void
55+
"""
56+
let declarations = extractDeclarations(from: source)
57+
XCTAssertEqual(declarations.count, 1)
58+
XCTAssertTrue(declarations[0].is(TypeAliasDeclSyntax.self))
59+
}
60+
61+
func testExtractsExtensionDeclarations() {
62+
let source = """
63+
extension String {
64+
var isEmpty: Bool { count == 0 }
65+
}
66+
"""
67+
let declarations = extractDeclarations(from: source)
68+
XCTAssertEqual(declarations.count, 1)
69+
XCTAssertTrue(declarations[0].is(ExtensionDeclSyntax.self))
70+
}
71+
72+
func testExtractsMultipleDeclarations() {
73+
let source = """
74+
public struct MyModel {
75+
let name: String
76+
}
77+
public protocol MyProtocol {
78+
func execute()
79+
}
80+
public enum State {
81+
case active
82+
}
83+
extension MyModel: MyProtocol {
84+
func execute() {}
85+
}
86+
"""
87+
let declarations = extractDeclarations(from: source)
88+
XCTAssertEqual(declarations.count, 4)
89+
}
90+
91+
func testSkipsChildDeclarations() {
92+
let source = """
93+
public struct Outer {
94+
struct Inner {
95+
let value: Int
96+
}
97+
let inner: Inner
98+
}
99+
"""
100+
let declarations = extractDeclarations(from: source)
101+
// Should only extract Outer, not Inner (skipChildren)
102+
XCTAssertEqual(declarations.count, 1)
103+
}
104+
105+
func testIgnoresFunctionDeclarations() {
106+
let source = """
107+
func topLevelFunction() {}
108+
"""
109+
let declarations = extractDeclarations(from: source)
110+
XCTAssertEqual(declarations.count, 0)
111+
}
112+
113+
// MARK: - Helpers
114+
115+
private func extractDeclarations(from source: String) -> [DeclSyntax] {
116+
let sourceFile = Parser.parse(source: source)
117+
let extractor = DeclarationExtractor(viewMode: .sourceAccurate)
118+
extractor.walk(sourceFile)
119+
return extractor.extractedDeclarations
120+
}
121+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import XCTest
2+
@testable import generateInterface
3+
4+
final class ModuleInterfaceRewriterTests: XCTestCase {
5+
func testRemovesUnderscoreImports() {
6+
let source = """
7+
import Foundation
8+
import _Concurrency
9+
import _StringProcessing
10+
import UIKit
11+
12+
public struct MyModel {}
13+
"""
14+
let result = rewriteModuleInterface(sourceText: source, additionalFiles: [], moduleName: "MyModule")!
15+
XCTAssertTrue(result.contains("import Foundation"))
16+
XCTAssertTrue(result.contains("import UIKit"))
17+
XCTAssertFalse(result.contains("_Concurrency"))
18+
XCTAssertFalse(result.contains("_StringProcessing"))
19+
}
20+
21+
func testRemovesSomeKeyword() {
22+
let source = """
23+
import Foundation
24+
25+
public struct MyView {
26+
public var body: some View { fatalError() }
27+
}
28+
"""
29+
let result = rewriteModuleInterface(sourceText: source, additionalFiles: [], moduleName: "MyModule")!
30+
XCTAssertFalse(result.contains("some View"))
31+
XCTAssertTrue(result.contains("View"))
32+
}
33+
34+
func testRemovesAnyKeyword() {
35+
let source = """
36+
import Foundation
37+
38+
public func handle(error: any Error) {}
39+
"""
40+
let result = rewriteModuleInterface(sourceText: source, additionalFiles: [], moduleName: "MyModule")!
41+
XCTAssertFalse(result.contains("any Error"))
42+
XCTAssertTrue(result.contains("Error"))
43+
}
44+
45+
func testRemovesBuilderClasses() {
46+
let source = """
47+
import Foundation
48+
49+
public class MyService {}
50+
public class MyBuilder: Builder {}
51+
"""
52+
let result = rewriteModuleInterface(sourceText: source, additionalFiles: [], moduleName: "MyModule")!
53+
XCTAssertTrue(result.contains("MyService"))
54+
XCTAssertFalse(result.contains("MyBuilder"))
55+
}
56+
57+
func testSimplifiesMemberTypeSyntax() {
58+
let source = """
59+
import Foundation
60+
61+
public func getValue() -> Swift.String { "" }
62+
"""
63+
let result = rewriteModuleInterface(sourceText: source, additionalFiles: [], moduleName: "MyModule")!
64+
// MemberTypeSyntax (Swift.String) should be simplified to just the name (String)
65+
XCTAssertTrue(result.contains("String"))
66+
}
67+
68+
func testPreservesRegularImports() {
69+
let source = """
70+
import Foundation
71+
import UIKit
72+
import Combine
73+
74+
public struct MyModel {}
75+
"""
76+
let result = rewriteModuleInterface(sourceText: source, additionalFiles: [], moduleName: "MyModule")!
77+
XCTAssertTrue(result.contains("import Foundation"))
78+
XCTAssertTrue(result.contains("import UIKit"))
79+
XCTAssertTrue(result.contains("import Combine"))
80+
}
81+
82+
func testReplacesDeclarationsWithSourceVersions() throws {
83+
let interfaceSource = """
84+
import Foundation
85+
86+
public struct MyModel {
87+
public let id: String
88+
public let name: String
89+
}
90+
"""
91+
92+
// Create a temp file with the "source" version of the declaration
93+
let sourceContent = """
94+
/// A model representing a user.
95+
public struct MyModel {
96+
public let id: String
97+
public let name: String
98+
99+
public init(id: String, name: String) {
100+
self.id = id
101+
self.name = name
102+
}
103+
}
104+
"""
105+
let tempFile = NSTemporaryDirectory() + "TestSource.swift"
106+
try sourceContent.write(toFile: tempFile, atomically: true, encoding: .utf8)
107+
defer { try? FileManager.default.removeItem(atPath: tempFile) }
108+
109+
let result = rewriteModuleInterface(sourceText: interfaceSource, additionalFiles: [tempFile], moduleName: "MyModule")!
110+
// Should use the source version which includes the doc comment and init
111+
XCTAssertTrue(result.contains("A model representing a user"))
112+
XCTAssertTrue(result.contains("public init"))
113+
}
114+
}

0 commit comments

Comments
 (0)