diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index b4df8b6..6cdf311 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -21,32 +21,12 @@ concurrency: cancel-in-progress: true jobs: - lint: - runs-on: macos-26 - steps: - - uses: actions/checkout@v6 - - - name: swiftformat --lint - run: | - swiftformat --lint --config .swiftformat . build: runs-on: macos-26 steps: - uses: actions/checkout@v6 - - name: Check Cocoapod Version - run: pod --version - - - name: Check Cocoapod Path - run: which pod - - - name: Update Cocoapod Repo - run: pod repo update - - # - name: Get Pod Spec - # run: pod spec cat --regex Bagel --version=1.4.0 - - name: Check Xcode Version run: | xcodebuild -version @@ -99,13 +79,23 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Bazel iOS Cache + # Every `bazel` in this job reads these: the disk cache holds what the + # build produced, the repository cache what it downloaded. Without the + # second one a runner fetches rules_apple and the rest again every time. + - name: Bazel Cache uses: actions/cache@v5 with: - path: fixture/iOS/cache - key: ${{ runner.os }}-IntegrateIOS-fixture-ios-${{ hashFiles('**/fixture/iOS/WORKSPACE') }} + path: | + ~/bazel-disk + ~/bazel-repo + key: ${{ runner.os }}-bazel-IntegrateIOS-${{ github.run_id }} restore-keys: | - ${{ runner.os }}-IntegrateIOS-fixture-ios- + ${{ runner.os }}-bazel-IntegrateIOS- + + - name: Bazel Cache Settings + run: | + echo "build --disk_cache=$HOME/bazel-disk" >> ~/.bazelrc + echo "build --repository_cache=$HOME/bazel-repo" >> ~/.bazelrc - name: Download Bazelize uses: actions/download-artifact@v8 @@ -118,35 +108,262 @@ jobs: pwd chmod +x bazelize + # A plugin that did not run leaves the target missing the sources it + # generates, and the compile error that follows names those sources rather + # than the plugin. The run says so; the lane stops there. - name: Bazel Generation working-directory: fixture/iOS run: | - ../../bazelize --project Example.xcodeproj - - - name: Update SPM Deps - working-directory: fixture/iOS - run: | - bazel mod tidy + ../../bazelize --project Example.xcodeproj --output App | tee bazelize.log + ! grep -q "did not run the" bazelize.log - name: Build Application - working-directory: fixture/iOS + working-directory: fixture/iOS/App run: | - bazel build Example - + bazel build //Targets/Example + - name: Copy IPA - working-directory: fixture/iOS + working-directory: fixture/iOS/App + run: | + cp "$(bazel cquery //Targets/Example --output=files | grep '\.ipa$')" ../Example.ipa + + # A simulator test on a runner pays for booting the simulator, and four of + # them booting at once pay for it four times over: the first test took + # 247s here and the last timed out at 302s having never started. One + # simulator is booted up front, outside any test's clock, and the tests + # run one at a time so they share it. + # + # Which simulator is the runner's business, not ours: it has whatever + # runtime its Xcode ships, which is not the one this repository is + # developed against. + - name: Boot Simulator run: | - cp bazel-bin/Example/Example.ipa . + # A device name has spaces in it, so one field per line. + { read -r device; read -r version; read -r udid; } < <(python3 - <<'PY' + import json, subprocess + + def simctl(*arguments): + listed = subprocess.run(["xcrun", "simctl", "list", "-j", *arguments], capture_output=True, text=True) + return json.loads(listed.stdout) + + runtimes = [runtime for runtime in simctl("runtimes")["runtimes"] + if runtime["identifier"].startswith("com.apple.CoreSimulator.SimRuntime.iOS") + and runtime.get("isAvailable")] + runtimes.sort(key=lambda runtime: [int(part) for part in runtime["version"].split(".")]) + runtime = runtimes[-1] + + devices = [device for device in simctl("devices", "available")["devices"].get(runtime["identifier"], []) + if device["name"].startswith("iPhone")] + if devices: + name, udid = devices[-1]["name"], devices[-1]["udid"] + else: + kinds = [kind for kind in simctl("devicetypes")["devicetypes"] if kind["name"].startswith("iPhone")] + name = kinds[-1]["name"] + created = subprocess.run( + ["xcrun", "simctl", "create", name, kinds[-1]["identifier"], runtime["identifier"]], + capture_output=True, text=True) + udid = created.stdout.strip() + + print(name) + print(runtime["version"]) + print(udid) + PY + ) + echo "Simulator: $device, iOS $version ($udid)" + echo "SIMULATOR_DEVICE=$device" >> "${GITHUB_ENV:-/dev/null}" + echo "SIMULATOR_VERSION=$version" >> "${GITHUB_ENV:-/dev/null}" + xcrun simctl boot "$udid" || true + xcrun simctl bootstatus "$udid" - name: Unit Test - working-directory: fixture/iOS + working-directory: fixture/iOS/App run: | - bazel test ExampleTests Framework1Tests Framework2Tests Framework3Tests + bazel test \ + --@build_bazel_rules_apple//apple/build_settings:ios_simulator_device="$SIMULATOR_DEVICE" \ + --@build_bazel_rules_apple//apple/build_settings:ios_simulator_version="$SIMULATOR_VERSION" \ + --local_test_jobs=1 \ + --test_timeout=900 \ + --test_output=errors \ + //Targets/ExampleTests \ + //Targets/Framework1Tests \ + //Targets/Framework2Tests \ + //Targets/Framework3Tests - name: Upload iOS Artifact uses: actions/upload-artifact@v7 with: name: iOS_Example.ipa path: fixture/iOS/Example.ipa - if-no-files-found: ignore # 'warn' or 'ignore' - + if-no-files-found: ignore # 'warn' or 'ignore' + + # The corpus is not in this repository: `app/` and `spm/` are ignored, so each + # lane clones what it measures at the revision it was measured against. + IntegrateApp: + runs-on: macos-26 + needs: [artifact] + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - name: stats + repo: https://github.com/exelban/stats + rev: b4251e2ba05511de6b51b296d7b9e98a9c6545f5 + project: Stats.xcodeproj + target: //Targets/Stats + - name: MonitorControl + repo: https://github.com/MonitorControl/MonitorControl + rev: 71b8c5ae51955d17b05688371ea7b899b684bdbd + project: MonitorControl.xcodeproj + target: //Targets/MonitorControl + - name: SwiftBar + repo: https://github.com/swiftbar/SwiftBar + rev: 05cb6cb1123bc7227ee2b39f390f8bccb839fed0 + project: SwiftBar.xcodeproj + target: //Targets/SwiftBar + - name: Rectangle + repo: https://github.com/rxhanson/Rectangle + rev: b4c9c47c00b4df9cbfb3489dc5eae2f1249ad24a + project: Rectangle.xcodeproj + target: //... + - name: VirtualBuddy + repo: https://github.com/insidegui/VirtualBuddy + rev: 088351b0fc67e0b24b83e7954ad48314dda4ce04 + project: VirtualBuddy.xcodeproj + target: //Targets/VirtualBuddy + # Not yet green on a runner, so not yet a lane. iina is the app the + # notes still list an open problem for, and MacPass needs its + # submodules and a Carthage bootstrap that nothing here has proven. + # - name: iina + # repo: https://github.com/iina/iina + # rev: c111221ea027466b79b40bfca054772d4851e06f + # project: iina.xcodeproj + # target: //Targets/iina + # - name: MacPass + # repo: https://github.com/MacPass/MacPass + # rev: 3256bc93ea94eb20b618c155e6a1b08e6fabe663 + # project: MacPass.xcodeproj + # target: //Targets/MacPass + # submodules: true + # setup: carthage bootstrap --platform macOS --cache-builds + steps: + # The disk cache holds what a build produced, the repository cache what it + # downloaded, and `.build` what SwiftPM resolved for the app's packages: + # generation resolves that graph before a single rule is written. + - name: Bazel Cache + uses: actions/cache@v5 + with: + path: | + ~/bazel-disk + ~/bazel-repo + key: ${{ runner.os }}-bazel-${{ matrix.name }}-${{ matrix.rev }}-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-bazel-${{ matrix.name }}-${{ matrix.rev }}- + ${{ runner.os }}-bazel-${{ matrix.name }}- + + - name: Bazel Cache Settings + run: | + echo "build --disk_cache=$HOME/bazel-disk" >> ~/.bazelrc + echo "build --repository_cache=$HOME/bazel-repo" >> ~/.bazelrc + + - name: Download Bazelize + uses: actions/download-artifact@v8 + with: + name: bazelize + + - name: Clone ${{ matrix.name }} + run: | + chmod +x bazelize + git clone --filter=blob:none "${{ matrix.repo }}" "${{ matrix.name }}" + git -C "${{ matrix.name }}" checkout --detach "${{ matrix.rev }}" + + # After the clone: a restored cache would otherwise create the directory + # `git clone` insists on creating itself. + - name: SwiftPM Cache + uses: actions/cache@v5 + with: + path: ${{ matrix.name }}/App/.build + key: ${{ runner.os }}-spm-${{ matrix.name }}-${{ matrix.rev }} + restore-keys: | + ${{ runner.os }}-spm-${{ matrix.name }}- + + - name: Check out submodules + if: matrix.submodules + working-directory: ${{ matrix.name }} + run: git submodule update --init --recursive + + - name: Set up ${{ matrix.name }} + if: matrix.setup + working-directory: ${{ matrix.name }} + run: ${{ matrix.setup }} + + - name: Bazel Generation + run: | + ./bazelize --project "${{ matrix.name }}/${{ matrix.project }}" --output "${{ matrix.name }}/App" | tee bazelize.log + ! grep -q "did not run the" bazelize.log + + - name: Build Application + working-directory: ${{ matrix.name }}/App + run: bazel build ${{ matrix.target }} + + # A package whose tests only compile through a source its own build tool + # plugin generates. The plugin and its tool are built by Bazel, so this + # holds on a toolchain that cannot load the package with SwiftPM at all. + IntegratePackage: + runs-on: macos-26 + needs: [artifact] + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - name: TbCodeGenerater + repo: https://github.com/yume190/TbCodeGenerater + rev: b7071d5e67189f48d5d71e06225dd1cb02470995 + target: //Packages/TbCodeGenerater/... + steps: + - name: Bazel Cache + uses: actions/cache@v5 + with: + path: | + ~/bazel-disk + ~/bazel-repo + key: ${{ runner.os }}-bazel-${{ matrix.name }}-${{ matrix.rev }}-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-bazel-${{ matrix.name }}-${{ matrix.rev }}- + ${{ runner.os }}-bazel-${{ matrix.name }}- + + - name: Bazel Cache Settings + run: | + echo "build --disk_cache=$HOME/bazel-disk" >> ~/.bazelrc + echo "build --repository_cache=$HOME/bazel-repo" >> ~/.bazelrc + + - name: Download Bazelize + uses: actions/download-artifact@v8 + with: + name: bazelize + + - name: Clone ${{ matrix.name }} + run: | + chmod +x bazelize + git clone --filter=blob:none "${{ matrix.repo }}" "${{ matrix.name }}" + git -C "${{ matrix.name }}" checkout --detach "${{ matrix.rev }}" + + # The package's directory name is the directory its rules live under, so + # the clone is named after the package rather than after the lane. + # Generation runs the plugins with what it has, which on this toolchain is + # not enough to build the plugin's tool — `//:plugins` is what builds the + # plugin and the tool with Bazel and runs them, so that is what the lane + # checks. Nothing else is regenerated: the rules glob the directory. + - name: Bazel Generation + run: ./bazelize --project "${{ matrix.name }}" --output "${{ matrix.name }}/App" + + - name: Run Plugins + working-directory: ${{ matrix.name }}/App + run: | + PATH="$GITHUB_WORKSPACE:$PATH" bazel run //:plugins + + - name: Test Package + working-directory: ${{ matrix.name }}/App + run: bazel test ${{ matrix.target }} + diff --git a/.gitignore b/.gitignore index d7efbf2..6bcbb48 100644 --- a/.gitignore +++ b/.gitignore @@ -183,4 +183,13 @@ cache/ .codex-local-skills/ .vendor/ -app/ \ No newline at end of file +app/ +spm/ +Generated/ + +# bazelize output inside the iOS fixture: one directory, the one `make bazelize` +# writes +fixture/iOS/App/ + +# running a local package's build tool plugins resolves that package +fixture/iOS/Local1/Package.resolved diff --git a/Makefile b/Makefile index 0db27c2..6bd70c6 100644 --- a/Makefile +++ b/Makefile @@ -30,30 +30,16 @@ build: format .PHONY: test test: - swift test -v --skip CocoapodTests 2>&1 | xcpretty -# COCOAPOD=$(shell which pod) swift test -v 2>&1 | xcbeautify - -Apple := bazelbuild/rules_apple -Swift := bazelbuild/rules_swift -XCodeProj := buildbuddy-io/rules_xcodeproj -REPOS := Apple Swift XCodeProj - -SPM := cgrindel/rules_swift_package_manager -REPO_SPM := SPM - - -# user/repo rule_name output_file_path -# python3 git_release.py bazelbuild/rules_apple Apple Sources/BazelizeKit/Rule/Rule+Apple.swift -$(REPOS): - python3 git_release.py $($@) $@ Sources/BazelizeKit/Repo/Repo+$@.swift 5 normal - -$(REPO_SPM): - python3 git_release.py $($@) $@ Sources/BazelizeKit/Repo/Repo+$@.swift 5 archive - -rules: $(REPOS) - -spm: $(REPO_SPM) + swift test -v 2>&1 | xcpretty .PHONY: bazelize bazelize: install cd fixture/iOS && make bazelize + +.PHONY: update-repo-enums +update-repo-enums: + swift package plugin --allow-network-connections all --allow-writing-to-package-directory repo-enum + +.PHONY: replace +replace: update-repo-enums + cp Generated/*.swift Sources/BazelizeKit/BazelDep/ diff --git a/Notes.md b/Notes.md new file mode 100644 index 0000000..7f53ba3 --- /dev/null +++ b/Notes.md @@ -0,0 +1,35 @@ + +iina: + +部分 dylib 是 source? +```shell +Targets/iina/Sources/iina/MPVController.swift:152:29: error: cannot find 'MPV_FORMAT_FLAG' in scope + 150 | MPVOption.Equalizer.saturation: MPV_FORMAT_INT64, + 151 | MPVOption.Window.fullscreen: MPV_FORMAT_FLAG, + 152 | MPVOption.Window.ontop: MPV_FORMAT_FLAG, + | `- error: cannot find 'MPV_FORMAT_FLAG' in scope + 153 | MPVOption.Window.windowScale: MPV_FORMAT_DOUBLE, + 154 | MPVProperty.mediaTitle: MPV_FORMAT_STRING, +``` + +plist 的 `$(xxx)`:專案自己宣告的(pbxproj/xcconfig)與 Xcode 從 toolchain 帶入的 +(`SDK_VERSION`、`XCODE_VERSION_*`、`SDK_NAME`、`PLATFORM_NAME`、`CONFIGURATION`) +都在 Swift 層取代掉了,剩下 `plisttool` 自己認的那幾個原樣交給 rules_apple。 +只存在於 CI 環境或 secret 的設定仍然解不出來——那種 key 會被丟掉並具名回報。 + +CI(`.github/workflows/swift.yml`)目前沒跑的: + +- **iina**:matrix 裡註解掉,等上面那條 dylib 的問題解掉、在 runner 上綠過再打開。 +- **MacPass**:需要 submodule 加 `carthage bootstrap`,那一步沒在 runner 上驗證過。 +- **TbCodeGenerater**:plugin 的 tool 在 Swift 6.3 是用 product 名去查的,那個 package + 的 product 叫 `tbCodeGenerater` 而 target 叫 `TbCodeGenerater`,所以整個 package + 在 runner 上建不起來(`no product named 'TbCodeGenerater'`)。改名之後要更新 CI 裡 + 釘的 revision。 + +runner 是 Xcode 26(Swift 6.3),本機是 27(6.4),兩者對 build tool plugin 的差別: + +- 6.3 用 product 名查 plugin 的 tool,6.4 接受 target 名。 +- 6.3 不為 C 系 target 跑 build tool plugin,而且**回報成功**——沒有產物也沒有錯誤。 + 6.4 會跑。fixture 的 ObjC target 因此不呼叫 plugin 產生的符號。 +- 沒宣告 `platforms:` 的 package,6.3 用它支援的最舊 macOS 去建 macro,會和 + swift-syntax 宣告的版本打架。 diff --git a/Package.resolved b/Package.resolved index ed19fbf..3b0c719 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "367c470b5fb9aa556b189bf5dff00798443731c0091aef8a9932c4496586252c", "pins" : [ { "identity" : "aexml", @@ -41,80 +42,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { - "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", - "version" : "1.7.0" + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" } }, { - "identity" : "swift-asn1", + "identity" : "swift-subprocess", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-asn1.git", + "location" : "https://github.com/swiftlang/swift-subprocess", "state" : { - "revision" : "9f542610331815e29cc3821d3b6f488db8715517", - "version" : "1.6.0" - } - }, - { - "identity" : "swift-certificates", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-certificates.git", - "state" : { - "revision" : "2f797305c1b5b982acaa6005d8a9f970cc4e97ff", - "version" : "1.5.0" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "c11818f3cae0780656baa430b49e7f163f08dffd", - "version" : "1.1.6" - } - }, - { - "identity" : "swift-crypto", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-crypto.git", - "state" : { - "revision" : "629f0b679d0fd0a6ae823d7f750b9ab032c00b80", - "version" : "3.0.0" - } - }, - { - "identity" : "swift-driver", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-driver.git", - "state" : { - "branch" : "release/6.2", - "revision" : "aedacc6c1583db4f2989a367e3c41968558a5b8e" - } - }, - { - "identity" : "swift-llbuild", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-llbuild.git", - "state" : { - "branch" : "release/6.2", - "revision" : "073dff55529d7c4ecbd615ab5f5ac52ae5b380da" - } - }, - { - "identity" : "swift-package-manager", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-package-manager", - "state" : { - "branch" : "swift-6.2.4-RELEASE", - "revision" : "215e9f91823d7e44c379fa17bf1eef189438fc24" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax.git", - "state" : { - "branch" : "release/6.2", - "revision" : "5a87516fc3dddbd23cb76358eb489915ee86b444" + "revision" : "b3937ab85dd32f6e9435914599c1519074769c1a", + "version" : "1.0.0" } }, { @@ -122,35 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", - "version" : "1.6.4" - } - }, - { - "identity" : "swift-toolchain-sqlite", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-toolchain-sqlite", - "state" : { - "revision" : "b45b80b943e88db3cb8ddea798fa3fa9912375ff", - "version" : "1.0.7" - } - }, - { - "identity" : "swift-tools-support-core", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-tools-support-core.git", - "state" : { - "branch" : "release/6.2", - "revision" : "5a993c8848487c934d53ffceef8e1cad0a241dc1" - } - }, - { - "identity" : "swiftcommand", - "kind" : "remoteSourceControl", - "location" : "https://github.com/yume190/SwiftCommand", - "state" : { - "revision" : "f82e9d3d65493aac2e5f819fb7f69cdaeb306bf5", - "version" : "1.1.3" + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" } }, { @@ -158,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/tuist/XcodeProj", "state" : { - "revision" : "01bb77000bc8c23a09ea2058f4954612f03cb705", - "version" : "9.10.1" + "revision" : "cfc3234fa2a60babbd26712ac0dec0d44734c019", + "version" : "9.16.0" } }, { @@ -167,10 +78,10 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/jpsim/Yams", "state" : { - "revision" : "deaf82e867fa2cbd3cd865978b079bfcf384ac28", - "version" : "6.2.1" + "revision" : "a27b21e0c81c5bf42049b897a62aaf387e80f279", + "version" : "6.2.2" } } ], - "version" : 2 + "version" : 3 } diff --git a/Package.swift b/Package.swift index 1877008..80ac670 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "Bazelize", platforms: [ - .macOS(.v13), + .macOS(.v14), ], products: [ .executable(name: "bazelize", targets: ["Bazelize"]), @@ -14,20 +14,14 @@ let package = Package( dependencies: [ // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/tuist/XcodeProj", from: "9.10.1"), + .package(url: "https://github.com/tuist/XcodeProj", from: "9.16.0"), .package(url: "https://github.com/Flight-School/AnyCodable", from: "0.6.7"), - .package(url: "https://github.com/jpsim/Yams", from: "6.2.1"), + .package(url: "https://github.com/jpsim/Yams", from: "6.2.2"), .package(url: "https://github.com/kylef/PathKit", from: "1.0.1"), - .package(url: "https://github.com/yume190/SwiftCommand", from: "1.1.3"), + .package(url: "https://github.com/swiftlang/swift-subprocess", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.0"), - - /// tag: swift-DEVELOPMENT-SNAPSHOT-2023-01-28-a - /// support async command - .package( - url: "https://github.com/apple/swift-package-manager", - branch: "swift-6.2.4-RELEASE"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.8.2"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -38,6 +32,13 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser"), "PathKit", "BazelizeKit", + "Xcode", + ]), + .executableTarget( + name: "RepoEnumGenerator", + dependencies: [ + "RepoEnumCore", + .product(name: "ArgumentParser", package: "swift-argument-parser"), ]), .target( @@ -61,15 +62,38 @@ let package = Package( name: "BazelizeKit", dependencies: [ "Yams", + "PathKit", "BazelRules", - "XCode", + "Xcode", "Util", "Starlark", "PluginLoader", + .product(name: "Subprocess", package: "swift-subprocess"), .product(name: "XcodeProj", package: "XcodeProj"), ]), + .target( + name: "RepoEnumCore", + dependencies: [ + "Yams", + ]), + .plugin( + name: "RepoEnumPlugin", + capability: .command( + intent: .custom( + verb: "repo-enum", + description: "Generate Repo+*.swift files from GitHub tags."), + permissions: [ + .allowNetworkConnections( + scope: .all(), + reason: "Fetch GitHub tags for configured repositories."), + .writeToPackageDirectory( + reason: "Write generated Repo enum files into the package directory."), + ]), + dependencies: [ + "RepoEnumGenerator", + ]), .target( name: "Util", @@ -82,25 +106,26 @@ let package = Package( dependencies: ["Util"]), .target( - name: "XCode", + name: "Xcode", dependencies: [ - "Util", - "Starlark", + "PathKit", "AnyCodable", .product(name: "XcodeProj", package: "XcodeProj"), - .product(name: "SwiftPMDataModel-auto", package: "swift-package-manager"), ]), .testTarget( - name: "XCodeTests", - dependencies: ["XCode"]), + name: "XcodeTests", + dependencies: ["Xcode", "BazelizeKit"]), + .testTarget( + name: "RepoEnumCoreTests", + dependencies: ["RepoEnumCore"]), .target( name: "PluginLoader", dependencies: [ "PathKit", "Util", - "XCode", - "SwiftCommand", + "Xcode", + .product(name: "Subprocess", package: "swift-subprocess"), ]), ]) diff --git a/Plugins/RepoEnumPlugin/plugin.swift b/Plugins/RepoEnumPlugin/plugin.swift new file mode 100644 index 0000000..4af7b95 --- /dev/null +++ b/Plugins/RepoEnumPlugin/plugin.swift @@ -0,0 +1,35 @@ +import Foundation +import PackagePlugin + +// MARK: - RepoEnumPlugin + +@main +struct RepoEnumPlugin: CommandPlugin { + func performCommand(context: PluginContext, arguments: [String]) async throws { + let tool = try context.tool(named: "RepoEnumGenerator") + let process = Process() + process.executableURL = tool.url + process.currentDirectoryURL = context.package.directoryURL + process.arguments = arguments + + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + throw RepoEnumPluginError.executionFailed(status: process.terminationStatus) + } + } +} + +// MARK: - RepoEnumPluginError + +private enum RepoEnumPluginError: LocalizedError { + case executionFailed(status: Int32) + + var errorDescription: String? { + switch self { + case .executionFailed(let status): + return "RepoEnumGenerator exited with status \(status)." + } + } +} diff --git a/README.md b/README.md index ce91daa..719ebb1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Bazelize -A cli tool turn your xcode project to bazel. +A cli tool turn your xcode project or Swift package to bazel. --- @@ -16,6 +16,12 @@ mint install XCodeBazelize/Bazelize bazelize --project YOUR.xcodeproj ``` +Or a Swift package — the `Package.swift`, or the directory holding one: + +```sh +bazelize --project path/to/Package.swift +``` + --- ## Bazel @@ -47,7 +53,7 @@ bazelize --project YOUR.xcodeproj ### Config -All `XCode configs` is stored in `BUILD` file. +All `Xcode configs` is stored in `BUILD` file. You can build debug version with following code. diff --git a/RepoSources.yml b/RepoSources.yml new file mode 100644 index 0000000..a299d2c --- /dev/null +++ b/RepoSources.yml @@ -0,0 +1,29 @@ +- name: Apple + url: https://github.com/bazelbuild/rules_apple + module: rules_apple +- name: Swift + url: https://github.com/bazelbuild/rules_swift + module: rules_swift +- name: XcodeProj + url: https://github.com/MobileNativeFoundation/rules_xcodeproj + module: rules_xcodeproj +- name: SwiftPM + url: https://github.com/cgrindel/rules_swift_package_manager + module: rules_swift_package_manager +- name: AppleLinker + url: https://github.com/keith/rules_apple_linker + module: rules_apple_linker +- name: Bazel + url: https://github.com/bazelbuild/bazel +- name: BazelSkylib + url: https://github.com/bazelbuild/bazel-skylib + module: bazel_skylib +- name: RulesCC + url: https://github.com/bazelbuild/rules_cc + module: rules_cc +- name: RulesShell + url: https://github.com/bazelbuild/rules_shell + module: rules_shell +- name: AppleSupport + url: https://github.com/bazelbuild/apple_support + module: apple_support diff --git a/Sources/BazelRules/Rules+Apple.swift b/Sources/BazelRules/Rules+Apple.swift index 1af0a08..f39c5df 100644 --- a/Sources/BazelRules/Rules+Apple.swift +++ b/Sources/BazelRules/Rules+Apple.swift @@ -46,6 +46,9 @@ extension Rules { case macos_bundle case macos_command_line_application case macos_extension + case macos_framework + case macos_static_framework + case macos_dynamic_framework case macos_ui_test case macos_unit_test @@ -112,6 +115,7 @@ extension Rules { } case apple_bundle_import + case apple_intent_library case apple_core_data_model case apple_core_ml_library case apple_resource_bundle @@ -149,9 +153,13 @@ extension Rules.Apple.IOS { /// Builds an `ios_application` target. public static func ios_application( name: String, + app_icons: Starlark.Value? = nil, bundle_id: String? = nil, bundle_name: String? = nil, deps: Starlark.Value? = nil, + entitlements: Starlark.Label? = nil, + extensions: [Starlark.Label]? = nil, + frameworks: [Starlark.Label]? = nil, families: [String]? = nil, infoplists: Starlark.Value? = nil, minimum_os_version: String? = nil, @@ -165,9 +173,13 @@ extension Rules.Apple.IOS { { Rules.Apple.IOS.ios_application.call { "name" => name + if let app_icons { "app_icons" => app_icons } if let bundle_id { "bundle_id" => bundle_id } if let bundle_name { "bundle_name" => bundle_name } if let deps { "deps" => deps } + if let entitlements { "entitlements" => entitlements } + if let extensions { "extensions" => extensions } + if let frameworks, !frameworks.isEmpty { "frameworks" => frameworks } if let families { "families" => families } if let infoplists { "infoplists" => infoplists } if let minimum_os_version { "minimum_os_version" => minimum_os_version } @@ -273,6 +285,7 @@ extension Rules.Apple.IOS { bundle_id: String? = nil, bundle_name: String? = nil, deps: Starlark.Value? = nil, + entitlements: Starlark.Label? = nil, families: [String]? = nil, infoplists: Starlark.Value? = nil, minimum_os_version: String? = nil, @@ -286,6 +299,7 @@ extension Rules.Apple.IOS { if let bundle_id { "bundle_id" => bundle_id } if let bundle_name { "bundle_name" => bundle_name } if let deps { "deps" => deps } + if let entitlements { "entitlements" => entitlements } if let families { "families" => families } if let infoplists { "infoplists" => infoplists } if let minimum_os_version { "minimum_os_version" => minimum_os_version } @@ -428,9 +442,14 @@ extension Rules.Apple.MacOS { /// Builds a `macos_application` target. public static func macos_application( name: String, + additional_contents: [String: String]? = nil, + app_icons: Starlark.Value? = nil, bundle_id: String? = nil, bundle_name: String? = nil, deps: Starlark.Value? = nil, + entitlements: Starlark.Label? = nil, + extensions: [Starlark.Label]? = nil, + frameworks: [Starlark.Label]? = nil, infoplists: Starlark.Value? = nil, minimum_os_version: String? = nil, resources: Starlark.Value? = nil, @@ -440,9 +459,16 @@ extension Rules.Apple.MacOS { { Rules.Apple.MacOS.macos_application.call { "name" => name + if let additional_contents, !additional_contents.isEmpty { + "additional_contents" => .init(additional_contents) ?? None + } + if let app_icons { "app_icons" => app_icons } if let bundle_id { "bundle_id" => bundle_id } if let bundle_name { "bundle_name" => bundle_name } if let deps { "deps" => deps } + if let entitlements { "entitlements" => entitlements } + if let extensions, !extensions.isEmpty { "extensions" => extensions } + if let frameworks, !frameworks.isEmpty { "frameworks" => frameworks } if let infoplists { "infoplists" => infoplists } if let minimum_os_version { "minimum_os_version" => minimum_os_version } if let resources { "resources" => resources } @@ -451,11 +477,38 @@ extension Rules.Apple.MacOS { } } + /// Builds a `macos_framework` target. + public static func macos_framework( + name: String, + bundle_id: String? = nil, + bundle_name: String? = nil, + deps: Starlark.Value? = nil, + infoplists: Starlark.Value? = nil, + minimum_os_version: String? = nil, + resources: Starlark.Value? = nil, + visibility: Starlark.Statement.Argument.Visibility? = nil) + -> Starlark.Statement.Call + { + Rules.Apple.MacOS.macos_framework.call { + "name" => name + if let bundle_id { "bundle_id" => bundle_id } + if let bundle_name { "bundle_name" => bundle_name } + if let deps { "deps" => deps } + if let infoplists { "infoplists" => infoplists } + if let minimum_os_version { "minimum_os_version" => minimum_os_version } + if let resources { "resources" => resources } + if let visibility { visibility } + } + } + public static func macos_extension( name: String, + additional_contents: [String: String]? = nil, bundle_id: String? = nil, bundle_name: String? = nil, deps: Starlark.Value? = nil, + entitlements: Starlark.Label? = nil, + frameworks: [Starlark.Label]? = nil, infoplists: Starlark.Value? = nil, minimum_os_version: String? = nil, resources: Starlark.Value? = nil, @@ -465,9 +518,46 @@ extension Rules.Apple.MacOS { { Rules.Apple.MacOS.macos_extension.call { "name" => name + if let additional_contents, !additional_contents.isEmpty { + "additional_contents" => .init(additional_contents) ?? None + } + if let bundle_id { "bundle_id" => bundle_id } + if let bundle_name { "bundle_name" => bundle_name } + if let deps { "deps" => deps } + if let entitlements { "entitlements" => entitlements } + if let frameworks, !frameworks.isEmpty { "frameworks" => frameworks } + if let infoplists { "infoplists" => infoplists } + if let minimum_os_version { "minimum_os_version" => minimum_os_version } + if let resources { "resources" => resources } + if let strings { "strings" => strings } + if let visibility { visibility } + } + } + + /// Builds a `macos_xpc_service` target. + public static func macos_xpc_service( + name: String, + additional_contents: [String: String]? = nil, + bundle_id: String? = nil, + bundle_name: String? = nil, + deps: Starlark.Value? = nil, + entitlements: Starlark.Label? = nil, + infoplists: Starlark.Value? = nil, + minimum_os_version: String? = nil, + resources: Starlark.Value? = nil, + strings: Starlark.Value? = nil, + visibility: Starlark.Statement.Argument.Visibility? = nil) + -> Starlark.Statement.Call + { + Rules.Apple.MacOS.macos_xpc_service.call { + "name" => name + if let additional_contents, !additional_contents.isEmpty { + "additional_contents" => .init(additional_contents) ?? None + } if let bundle_id { "bundle_id" => bundle_id } if let bundle_name { "bundle_name" => bundle_name } if let deps { "deps" => deps } + if let entitlements { "entitlements" => entitlements } if let infoplists { "infoplists" => infoplists } if let minimum_os_version { "minimum_os_version" => minimum_os_version } if let resources { "resources" => resources } @@ -941,12 +1031,14 @@ extension Rules.Apple.General { public static func apple_dynamic_xcframework_import( name: String, xcframework_imports: Starlark.Value, + tags: [String]? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call { Rules.Apple.General.apple_dynamic_xcframework_import.call { "name" => name "xcframework_imports" => xcframework_imports + if let tags { "tags" => tags } if let visibility { visibility } } } @@ -955,12 +1047,14 @@ extension Rules.Apple.General { public static func apple_static_xcframework_import( name: String, xcframework_imports: Starlark.Value, + tags: [String]? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call { Rules.Apple.General.apple_static_xcframework_import.call { "name" => name "xcframework_imports" => xcframework_imports + if let tags { "tags" => tags } if let visibility { visibility } } } @@ -1086,15 +1180,21 @@ extension Rules.Apple.Resources { /// Builds an `apple_resource_bundle` target. public static func apple_resource_bundle( name: String, + bundle_name: String? = nil, + infoplists: Starlark.Value? = nil, resources: Starlark.Value? = nil, structured_resources: Starlark.Value? = nil, + tags: [String]? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call { Rules.Apple.Resources.apple_resource_bundle.call { "name" => name + if let bundle_name { "bundle_name" => bundle_name } + if let infoplists { "infoplists" => infoplists } if let resources { "resources" => resources } if let structured_resources { "structured_resources" => structured_resources } + if let tags { "tags" => tags } if let visibility { visibility } } } @@ -1103,6 +1203,7 @@ extension Rules.Apple.Resources { public static func apple_resource_group( name: String, resources: Starlark.Value? = nil, + strip_structured_resources_prefixes: [String]? = nil, structured_resources: Starlark.Value? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call @@ -1110,6 +1211,9 @@ extension Rules.Apple.Resources { Rules.Apple.Resources.apple_resource_group.call { "name" => name if let resources { "resources" => resources } + if let strip_structured_resources_prefixes, !strip_structured_resources_prefixes.isEmpty { + "strip_structured_resources_prefixes" => strip_structured_resources_prefixes + } if let structured_resources { "structured_resources" => structured_resources } if let visibility { visibility } } @@ -1128,6 +1232,98 @@ extension Rules.Apple.Resources { if let visibility { visibility } } } + + /// Builds a `swift_intent_library` target. + /// + /// Parameters: + /// - `name: String` + /// The Bazel target name. + /// - `src: Starlark.Label` + /// The `.intentdefinition` file to generate classes from. + /// - `class_prefix: String?` + /// Class prefix for the generated classes. + /// - `class_visibility: String?` + /// Swift visibility of the generated classes: `public`, `private` or `project`. + /// - `swift_version: String?` + /// Swift language version used for the generated classes. + /// - `testonly: Bool?` + /// Repo-local convenience for emitting Bazel's `testonly` attribute. + /// - `visibility: Starlark.Statement.Argument.Visibility?` + /// Repo-local convenience for emitting a `visibility` attribute. + public static func swift_intent_library( + name: String, + src: Starlark.Label, + class_prefix: String? = nil, + class_visibility: String? = nil, + swift_version: String? = nil, + testonly: Bool? = nil, + visibility: Starlark.Statement.Argument.Visibility? = nil) + -> Starlark.Statement.Call + { + Rules.Apple.Resources.swift_intent_library.call { + "name" => name + "src" => src + if let class_prefix { "class_prefix" => class_prefix } + if let class_visibility { "class_visibility" => class_visibility } + if let swift_version { "swift_version" => swift_version } + if let testonly { "testonly" => testonly } + if let visibility { visibility } + } + } + + /// Builds an `apple_intent_library` target. + /// + /// Unlike `swift_intent_library` this exposes the generated sources directly, + /// so they can be compiled into the module that uses them — which is how + /// Xcode treats an `.intentdefinition` belonging to a target. + /// + /// Parameters: + /// - `name: String` + /// The Bazel target name. + /// - `src: Starlark.Label` + /// The `.intentdefinition` file to generate classes from. + /// - `language: String` + /// `Swift` or `Objective-C`. + /// - `class_prefix: String?` + /// Class prefix for the generated classes. + /// - `class_visibility: String?` + /// Swift visibility of the generated classes: `public`, `private` or `project`. + /// - `header_name: String?` + /// Generated header name, required for Objective-C. + /// - `swift_version: String?` + /// Swift language version used for the generated classes. + /// - `tags: [String]?` + /// Bazel tags; the rule is meant to be built only through its consumer. + /// - `testonly: Bool?` + /// Repo-local convenience for emitting Bazel's `testonly` attribute. + /// - `visibility: Starlark.Statement.Argument.Visibility?` + /// Repo-local convenience for emitting a `visibility` attribute. + public static func apple_intent_library( + name: String, + src: Starlark.Label, + language: String, + class_prefix: String? = nil, + class_visibility: String? = nil, + header_name: String? = nil, + swift_version: String? = nil, + tags: [String]? = nil, + testonly: Bool? = nil, + visibility: Starlark.Statement.Argument.Visibility? = nil) + -> Starlark.Statement.Call + { + Rules.Apple.Resources.apple_intent_library.call { + "name" => name + "src" => src + "language" => language + if let class_prefix { "class_prefix" => class_prefix } + if let class_visibility { "class_visibility" => class_visibility } + if let header_name { "header_name" => header_name } + if let swift_version { "swift_version" => swift_version } + if let tags { "tags" => tags } + if let testonly { "testonly" => testonly } + if let visibility { visibility } + } + } } } diff --git a/Sources/BazelRules/Rules+Builtin.swift b/Sources/BazelRules/Rules+Builtin.swift index 4f0bb30..86d401a 100644 --- a/Sources/BazelRules/Rules+Builtin.swift +++ b/Sources/BazelRules/Rules+Builtin.swift @@ -57,15 +57,36 @@ extension Rules.Builtin.Call { } } + public static func genrule( + name: String, + srcs: Starlark.Value, + outs: [String], + cmd: String, + visibility: Starlark.Statement.Argument.Visibility? = nil) + -> Starlark.Statement.Call + { + .init("genrule") { + "name" => name + "srcs" => srcs + "outs" => outs + "cmd" => Starlark.custom("\"\"\"\(cmd)\"\"\"") + if let visibility { + visibility.argument + } + } + } + public static func alias( name: String, actual: Starlark.Label, + tags: [String]? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call { .init("alias") { "name" => name "actual" => actual + if let tags { "tags" => tags } if let visibility { visibility.argument } diff --git a/Sources/BazelRules/Rules+Cc.swift b/Sources/BazelRules/Rules+Cc.swift new file mode 100644 index 0000000..861ee47 --- /dev/null +++ b/Sources/BazelRules/Rules+Cc.swift @@ -0,0 +1,74 @@ +import Foundation +import Starlark + +// MARK: - Rules.Cc + +extension Rules { + public enum Cc: String, LoadableRule { + case cc_import + case cc_library + + public var module: String { + "@rules_cc//cc:defs.bzl" + } + } +} + +// MARK: - Rules.Cc.Call + +extension Rules.Cc { + public enum Call { + public static func cc_import( + name: String, + shared_library: Starlark.Label? = nil, + static_library: Starlark.Label? = nil, + interface_library: Starlark.Label? = nil, + hdrs: Starlark.Value? = nil, + system_provided: Bool? = nil, + visibility: Starlark.Statement.Argument.Visibility? = nil) + -> Starlark.Statement.Call + { + Rules.Cc.cc_import.call { + "name" => name + if let shared_library { "shared_library" => shared_library } + if let static_library { "static_library" => static_library } + if let interface_library { "interface_library" => interface_library } + if let hdrs { "hdrs" => hdrs } + if let system_provided { "system_provided" => system_provided } + if let visibility { visibility } + } + } + + /// Builds a `cc_library` target. + /// + /// Reference: [Bazel `cc_library`](https://bazel.build/reference/be/c-cpp#cc_library) + public static func cc_library( + name: String, + aspect_hints: Starlark.Value? = nil, + srcs: Starlark.Value? = nil, + hdrs: Starlark.Value? = nil, + deps: Starlark.Value? = nil, + copts: [String]? = nil, + includes: [String]? = nil, + linkopts: [String]? = nil, + tags: [String]? = nil, + textual_hdrs: Starlark.Value? = nil, + visibility: Starlark.Statement.Argument.Visibility? = nil) + -> Starlark.Statement.Call + { + Rules.Cc.cc_library.call { + "name" => name + if let aspect_hints { "aspect_hints" => aspect_hints } + if let srcs { "srcs" => srcs } + if let hdrs { "hdrs" => hdrs } + if let deps { "deps" => deps } + if let copts { "copts" => copts } + if let includes { "includes" => includes } + if let linkopts { "linkopts" => linkopts } + if let tags { "tags" => tags } + if let textual_hdrs { "textual_hdrs" => textual_hdrs } + if let visibility { visibility } + } + } + } +} diff --git a/Sources/BazelRules/Rules+Objc.swift b/Sources/BazelRules/Rules+Objc.swift index 4d78a3d..80ec443 100644 --- a/Sources/BazelRules/Rules+Objc.swift +++ b/Sources/BazelRules/Rules+Objc.swift @@ -43,6 +43,7 @@ extension Rules.Objc { /// Reference: [Bazel `objc_library`](https://bazel.build/reference/be/objective-c#objc_library) public static func objc_library( name: String, + aspect_hints: Starlark.Value? = nil, srcs: Starlark.Value? = nil, hdrs: Starlark.Value? = nil, deps: Starlark.Value? = nil, @@ -50,6 +51,7 @@ extension Rules.Objc { alwayslink: Bool? = nil, copts: [String]? = nil, defines: [String]? = nil, + enable_modules: Bool? = nil, includes: [String]? = nil, linkopts: [String]? = nil, module_map: Starlark.Label? = nil, @@ -59,6 +61,7 @@ extension Rules.Objc { sdk_dylibs: [String]? = nil, sdk_frameworks: [String]? = nil, sdk_includes: [String]? = nil, + tags: [String]? = nil, textual_hdrs: Starlark.Value? = nil, testonly: Bool? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil, @@ -67,6 +70,7 @@ extension Rules.Objc { { Rules.Objc.objc_library.call { "name" => name + if let aspect_hints { "aspect_hints" => aspect_hints } if let srcs { "srcs" => srcs } if let hdrs { "hdrs" => hdrs } if let deps { "deps" => deps } @@ -74,6 +78,7 @@ extension Rules.Objc { if let alwayslink { "alwayslink" => alwayslink } if let copts { "copts" => copts } if let defines { "defines" => defines } + if let enable_modules { "enable_modules" => enable_modules } if let includes { "includes" => includes } if let linkopts { "linkopts" => linkopts } if let module_map { "module_map" => module_map } @@ -83,6 +88,7 @@ extension Rules.Objc { if let sdk_dylibs { "sdk_dylibs" => sdk_dylibs } if let sdk_frameworks { "sdk_frameworks" => sdk_frameworks } if let sdk_includes { "sdk_includes" => sdk_includes } + if let tags { "tags" => tags } if let textual_hdrs { "textual_hdrs" => textual_hdrs } if let testonly { "testonly" => testonly } if let visibility { visibility } diff --git a/Sources/BazelRules/Rules+Shell.swift b/Sources/BazelRules/Rules+Shell.swift new file mode 100644 index 0000000..1fbda3f --- /dev/null +++ b/Sources/BazelRules/Rules+Shell.swift @@ -0,0 +1,42 @@ +// +// Rules+Shell.swift +// +// +// The rule a workspace's own scripts are run with. +// + +import Foundation +import Starlark + +// MARK: - Rules.Shell + +extension Rules { + /// https://github.com/bazelbuild/rules_shell + public enum Shell: String, LoadableRule { + public var module: String { + "@rules_shell//shell:sh_binary.bzl" + } + + case sh_binary + } +} + +// MARK: - Rules.Shell.Call + +extension Rules.Shell { + public enum Call { + public static func sh_binary( + name: String, + srcs: [String], + data: [String] = []) -> Starlark.Statement.Call + { + Rules.Shell.sh_binary.call { + "name" => name + "srcs" => srcs + if !data.isEmpty { + "data" => data.map { Starlark.Label.named($0) } + } + } + } + } +} diff --git a/Sources/BazelRules/Rules+Swift.swift b/Sources/BazelRules/Rules+Swift.swift index 916f432..1740341 100644 --- a/Sources/BazelRules/Rules+Swift.swift +++ b/Sources/BazelRules/Rules+Swift.swift @@ -29,6 +29,8 @@ extension Rules { case swift_c_module /// `swift_overlay(name, deps, module_name, overlay_deps, srcs)`. case swift_overlay + /// `swift_interop_hint(name, exclude_hdrs, module_map, module_name, suppressed)`. + case swift_interop_hint /// `swift_library_group(name, deps, exports)`. case swift_library_group @@ -38,7 +40,7 @@ extension Rules { case swift_compiler_plugin /// `universal_swift_compiler_plugin(name, plugin, toolchain_types)`. case universal_swift_compiler_plugin - /// `mixed_language_library(name, module_name, srcs, deps, data, defines, copts)`. + /// `mixed_language_library(name, module_name, clang_srcs, swift_srcs, deps, data)`. case mixed_language_library /// `swift_feature_allowlist(name, package_groups)`. case swift_feature_allowlist @@ -61,6 +63,8 @@ extension Rules { "@build_bazel_rules_swift//swift:swift_c_module.bzl" case .swift_overlay: "@build_bazel_rules_swift//swift:swift_overlay.bzl" + case .swift_interop_hint: + "@build_bazel_rules_swift//swift:swift_interop_hint.bzl" case .swift_library_group: "@build_bazel_rules_swift//swift:swift_library_group.bzl" case .swift_compiler_plugin, .universal_swift_compiler_plugin: @@ -128,8 +132,11 @@ extension Rules.Swift { public static func swift_library( name: String, alwayslink: Bool = true, + always_include_developer_search_paths: Bool? = nil, copts: [String]? = nil, module_name: String? = nil, + package_name: String? = nil, + plugins: Starlark.Value? = nil, srcs: Starlark.Value, deps: Starlark.Value? = nil, data: Starlark.Value? = nil, @@ -140,6 +147,7 @@ extension Rules.Swift { linkstatic: Bool? = nil, private_deps: Starlark.Value? = nil, swiftc_inputs: Starlark.Value? = nil, + tags: [String]? = nil, testonly: Bool? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call @@ -147,12 +155,21 @@ extension Rules.Swift { Rules.Swift.swift_library.call { "name" => name "alwayslink" => alwayslink + if let always_include_developer_search_paths { + "always_include_developer_search_paths" => always_include_developer_search_paths + } if let copts { "copts" => copts } if let module_name { "module_name" => module_name } + if let package_name { + "package_name" => package_name + } + if let plugins { + "plugins" => plugins + } "srcs" => srcs if let deps { @@ -182,6 +199,9 @@ extension Rules.Swift { if let swiftc_inputs { "swiftc_inputs" => swiftc_inputs } + if let tags { + "tags" => tags + } if let testonly { "testonly" => testonly } @@ -228,6 +248,7 @@ extension Rules.Swift { srcs: Starlark.Value? = nil, stamp: Int? = nil, swiftc_inputs: Starlark.Value? = nil, + tags: [String]? = nil, testonly: Bool? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call @@ -255,6 +276,9 @@ extension Rules.Swift { if let swiftc_inputs { "swiftc_inputs" => swiftc_inputs } + if let tags { + "tags" => tags + } if let testonly { "testonly" => testonly } @@ -474,10 +498,48 @@ extension Rules.Swift { /// Dependencies re-exported by the group. /// - `visibility: Starlark.Statement.Argument.Visibility?` /// Repo-local convenience for emitting a `visibility` attribute. + /// Builds a `swift_interop_hint` target. + /// + /// Reference: [rules_swift `swift_interop_hint`](https://github.com/bazelbuild/rules_swift/blob/main/doc/rules.md#swift_interop_hint) + /// + /// Signature: + /// `swift_interop_hint(name, exclude_hdrs, module_map, module_name, suppressed)`. + /// + /// Parameters: + /// - `name: String` + /// The Bazel target name. + /// - `module_map: Starlark.Label?` + /// A module map written by hand, used instead of a generated one. + /// - `module_name: String?` + /// The module name a Swift target imports. + /// - `exclude_hdrs: Starlark.Value?` + /// Headers kept out of the generated module map. + /// - `suppressed: Bool?` + /// Hides the C target from Swift entirely. + public static func swift_interop_hint( + name: String, + module_map: Starlark.Label? = nil, + module_name: String? = nil, + exclude_hdrs: Starlark.Value? = nil, + suppressed: Bool? = nil, + visibility: Starlark.Statement.Argument.Visibility? = nil) + -> Starlark.Statement.Call + { + Rules.Swift.swift_interop_hint.call { + "name" => name + if let module_map { "module_map" => module_map } + if let module_name { "module_name" => module_name } + if let exclude_hdrs { "exclude_hdrs" => exclude_hdrs } + if let suppressed { "suppressed" => suppressed } + if let visibility { visibility } + } + } + public static func swift_library_group( name: String, deps: Starlark.Value? = nil, exports: Starlark.Value? = nil, + tags: [String]? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call { @@ -485,6 +547,7 @@ extension Rules.Swift { "name" => name if let deps { "deps" => deps } if let exports { "exports" => exports } + if let tags { "tags" => tags } if let visibility { visibility } } } @@ -500,13 +563,21 @@ extension Rules.Swift { /// Repo-local convenience for emitting a `visibility` attribute. public static func swift_compiler_plugin( name: String, + srcs: Starlark.Value? = nil, + copts: [String]? = nil, deps: Starlark.Value? = nil, + module_name: String? = nil, + tags: [String]? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call { Rules.Swift.swift_compiler_plugin.call { "name" => name + if let srcs { "srcs" => srcs } + if let copts { "copts" => copts } if let deps { "deps" => deps } + if let module_name { "module_name" => module_name } + if let tags { "tags" => tags } if let visibility { visibility } } } @@ -542,53 +613,133 @@ extension Rules.Swift { /// Parameters: /// - `name: String` /// The Bazel target name. - /// - `module_name: String?` - /// Swift module name exposed by the mixed-language target. - /// - `srcs: Starlark.Value?` - /// Swift and Objective-C source files in the target. - /// - `deps: Starlark.Value?` - /// Regular dependencies linked into the library. - /// - `data: Starlark.Value?` - /// Runtime data made available to the target. - /// - `defines: Starlark.Value?` - /// Compilation condition symbols, including `select(...)` expressions. - /// - `copts: [String]?` - /// C or Clang compilation flags. + /// - `additional_objc_compiler_inputs: Starlark.Value?` + /// Additional Objective-C compiler inputs. /// - `always_include_developer_search_paths: Bool?` /// Whether to include developer search paths when building. + /// - `alwayslink: Bool?` + /// Whether the library should always be linked. + /// - `clang_copts: [String]?` + /// C or Clang compilation flags. + /// - `clang_defines: Starlark.Value?` + /// Preprocessor definitions for Clang compilation. /// - `clang_deps: Starlark.Value?` /// Additional Clang-specific dependencies. + /// - `clang_srcs: Starlark.Value?` + /// C-family sources compiled by Clang. + /// - `data: Starlark.Value?` + /// Runtime data made available to the target. + /// - `enable_modules: Bool?` + /// Whether Clang modules are enabled for the target. + /// - `hdrs: Starlark.Value?` + /// Public C-family headers published by this mixed-language target. + /// - `includes: [String]?` + /// Header search paths exported by the target. + /// - `linkopts: [String]?` + /// Linker options passed through to dependents. + /// - `module_map: Starlark.Label?` + /// Explicit Clang module map. + /// - `module_name: String?` + /// Swift module name exposed by the mixed-language target. + /// - `non_arc_srcs: Starlark.Value?` + /// Objective-C sources that should compile without ARC. + /// - `sdk_dylibs: [String]?` + /// SDK dylibs to link, such as `sqlite3` or `libz`. + /// - `sdk_frameworks: [String]?` + /// SDK frameworks to link strongly. /// - `package_name: String?` /// Optional package name used for module/package identity. + /// - `private_deps: Starlark.Value?` + /// Dependencies that are private to the target implementation. + /// - `swift_copts: [String]?` + /// Swift compiler flags. + /// - `swift_defines: Starlark.Value?` + /// Swift compilation condition symbols, including `select(...)` expressions. + /// - `swift_plugins: Starlark.Value?` + /// Swift compiler plugins to apply. + /// - `swift_srcs: Starlark.Value?` + /// Swift sources compiled by `swiftc`. + /// - `swiftc_inputs: Starlark.Value?` + /// Extra inputs that should be available to the Swift compiler. + /// - `textual_hdrs: Starlark.Value?` + /// Textual headers consumed by Clang but not modularized. + /// - `umbrella_header: Starlark.Label?` + /// Umbrella header used for the generated module. + /// - `weak_sdk_frameworks: [String]?` + /// SDK frameworks to weakly link. + /// - `deps: Starlark.Value?` + /// Regular dependencies linked into the library. /// - `visibility: Starlark.Statement.Argument.Visibility?` /// Repo-local convenience for emitting a `visibility` attribute. public static func mixed_language_library( name: String, - module_name: String? = nil, - srcs: Starlark.Value? = nil, - deps: Starlark.Value? = nil, - data: Starlark.Value? = nil, - defines: Starlark.Value? = nil, - copts: [String]? = nil, + additional_objc_compiler_inputs: Starlark.Value? = nil, always_include_developer_search_paths: Bool? = nil, + alwayslink: Bool? = nil, + clang_copts: [String]? = nil, + clang_defines: Starlark.Value? = nil, clang_deps: Starlark.Value? = nil, + clang_srcs: Starlark.Value? = nil, + data: Starlark.Value? = nil, + enable_modules: Bool? = nil, + hdrs: Starlark.Value? = nil, + includes: [String]? = nil, + linkopts: [String]? = nil, + module_map: Starlark.Label? = nil, + module_name: String? = nil, + non_arc_srcs: Starlark.Value? = nil, package_name: String? = nil, + private_deps: Starlark.Value? = nil, + sdk_dylibs: [String]? = nil, + sdk_frameworks: [String]? = nil, + swift_copts: [String]? = nil, + swift_defines: Starlark.Value? = nil, + swift_plugins: Starlark.Value? = nil, + swift_srcs: Starlark.Value? = nil, + swiftc_inputs: Starlark.Value? = nil, + tags: [String]? = nil, + textual_hdrs: Starlark.Value? = nil, + umbrella_header: Starlark.Label? = nil, + weak_sdk_frameworks: [String]? = nil, + deps: Starlark.Value? = nil, visibility: Starlark.Statement.Argument.Visibility? = nil) -> Starlark.Statement.Call { Rules.Swift.mixed_language_library.call { "name" => name - if let module_name { "module_name" => module_name } - if let srcs { "srcs" => srcs } - if let deps { "deps" => deps } - if let data { "data" => data } - if let defines { "defines" => defines } - if let copts { "copts" => copts } + if let additional_objc_compiler_inputs { + "additional_objc_compiler_inputs" => additional_objc_compiler_inputs + } if let always_include_developer_search_paths { "always_include_developer_search_paths" => always_include_developer_search_paths } + if let alwayslink { "alwayslink" => alwayslink } + if let clang_copts { "clang_copts" => clang_copts } + if let clang_defines { "clang_defines" => clang_defines } if let clang_deps { "clang_deps" => clang_deps } + if let clang_srcs { "clang_srcs" => clang_srcs } + if let data { "data" => data } + if let enable_modules { "enable_modules" => enable_modules } + if let hdrs { "hdrs" => hdrs } + if let includes { "includes" => includes } + if let linkopts { "linkopts" => linkopts } + if let module_map { "module_map" => module_map } + if let module_name { "module_name" => module_name } + if let non_arc_srcs { "non_arc_srcs" => non_arc_srcs } if let package_name { "package_name" => package_name } + if let private_deps { "private_deps" => private_deps } + if let sdk_dylibs { "sdk_dylibs" => sdk_dylibs } + if let sdk_frameworks { "sdk_frameworks" => sdk_frameworks } + if let swift_copts { "swift_copts" => swift_copts } + if let swift_defines { "swift_defines" => swift_defines } + if let swift_plugins { "swift_plugins" => swift_plugins } + if let swift_srcs { "swift_srcs" => swift_srcs } + if let swiftc_inputs { "swiftc_inputs" => swiftc_inputs } + if let tags { "tags" => tags } + if let textual_hdrs { "textual_hdrs" => textual_hdrs } + if let umbrella_header { "umbrella_header" => umbrella_header } + if let weak_sdk_frameworks { "weak_sdk_frameworks" => weak_sdk_frameworks } + if let deps { "deps" => deps } if let visibility { visibility } } } diff --git a/Sources/Bazelize/Command.swift b/Sources/Bazelize/Command.swift index 95f3809..d6d8d19 100644 --- a/Sources/Bazelize/Command.swift +++ b/Sources/Bazelize/Command.swift @@ -9,17 +9,89 @@ import ArgumentParser import BazelizeKit import Foundation import PathKit +import Xcode + +// MARK: - Command @main struct Command: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "bazelize", abstract: "A cli tool turn your xcode project to bazel.", - version: version) + version: version, + subcommands: [ + GenerateCommand.self, + PluginsCommand.self, + XcodeCommand.self, +// RoadmapCommand.self, + ], + defaultSubcommand: GenerateCommand.self) +} + +// MARK: - PluginsCommand + +/// Runs the build tool plugins of a generated workspace, and nothing else. +/// +/// What a plugin writes is decided by the plugin, so a change to its own source +/// changes the files a target compiles without anything else about the project +/// moving. This is the command that brings those files up to date — the +/// generated workspace exposes it as `bazel run //:plugins`, the way a Bazel +/// workspace exposes every other thing that writes back into it. +struct PluginsCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "plugins", + abstract: "Run the build tool plugins of a generated workspace.") + + @Option(name: [.customLong("output", withSingleDash: false)], help: "PATH/TO/OUTPUT") + var output = "." + + @Option(name: [.customLong("local", withSingleDash: false)], help: "PATH/TO/LOCAL/PACKAGE") + var locals: [String] = [] + + /// `NAME=PATH`, for the programs Bazel built: `//:plugins` has them as + /// `data`, so a plugin runs without SwiftPM building anything. + @Option(name: [.customLong("plugin", withSingleDash: false)], help: "NAME=PATH/TO/PLUGIN") + var plugins: [String] = [] + + @Option(name: [.customLong("tool", withSingleDash: false)], help: "NAME=PATH/TO/TOOL") + var tools: [String] = [] + + func run() async throws { + let outputPath = Path.current + output + let notes = try await SwiftPM.runPlugins( + output: outputPath, + locals: locals.map { Path.current + $0 }, + plugins: Self.programs(plugins), + tools: Self.programs(tools)) + + for note in notes { + print(note) + } + } + + private static func programs(_ arguments: [String]) -> [String: Path] { + arguments.reduce(into: [:]) { programs, argument in + guard let separator = argument.firstIndex(of: "=") else { return } + let name = String(argument[.. [String] { + let platforms: [(flag: String, keyPath: KeyPath)] = [ + ("ios_minimum_os", \.platform.iOS), + ("macos_minimum_os", \.platform.macOS), + ("tvos_minimum_os", \.platform.tvOS), + ("watchos_minimum_os", \.platform.watchOS), + ] + + return platforms.compactMap { platform in + let versions = targets.compactMap { target in + target.prefer(platform.keyPath) + } + guard let highest = versions.max(by: Self.isOlder) else { return nil } + return "build --\(platform.flag)=\(highest)" + } + } + + private static func isOlder(_ lhs: String, _ rhs: String) -> Bool { + let left = lhs.split(separator: ".").compactMap { Int($0) } + let right = rhs.split(separator: ".").compactMap { Int($0) } + + for (l, r) in zip(left, right) where l != r { + return l < r + } + return left.count < right.count + } + } +} + +extension Bazel { + /// /.bazelrc + /// + /// Bazel only reads `config.bazelrc` when the root `.bazelrc` imports it, so + /// the generated flags are inert without this file. + struct RootRC { + static let importLine = "import %workspace%/config.bazelrc" + + let path: Path + + init(_ root: Path) { + path = root + ".bazelrc" + } + + /// Creates `.bazelrc` when missing and otherwise appends the import once, + /// because the file may be hand-written and carry unrelated flags. + func ensureImport() throws { + guard let existing = try? String(contentsOfFile: path.string, encoding: .utf8) else { + try path.write(Self.importLine + "\n") + return + } + + guard !existing.components(separatedBy: .newlines).contains(Self.importLine) else { return } + + let separator = existing.hasSuffix("\n") || existing.isEmpty ? "" : "\n" + try path.write(existing + separator + Self.importLine + "\n") } } } diff --git a/Sources/BazelizeKit/Bazel/Bazel+Module.swift b/Sources/BazelizeKit/Bazel/Bazel+Module.swift index ba3c12d..0f7e0df 100644 --- a/Sources/BazelizeKit/Bazel/Bazel+Module.swift +++ b/Sources/BazelizeKit/Bazel/Bazel+Module.swift @@ -14,6 +14,12 @@ extension Bazel { struct Module: BazelFile { let path: Path public let builder = CodeBuilder() + private let skylib: BazelDep.BazelSkylib = .latest + private let cc: BazelDep.RulesCC = .latest + private let appleSupport: BazelDep.AppleSupport = .latest + /// What `//:plugins` is a `sh_binary` of: Bazel itself no longer has + /// that rule. + private let shell: BazelDep.RulesShell = .latest init(_ root: Path) { path = root + "MODULE.bazel" @@ -30,8 +36,18 @@ extension Bazel { "name" => "example" "version" => "0.0.1" } - builder.bazel_dep(name: "bazel_skylib", version: "1.9.0") - builder.bazel_dep(name: "rules_cc", version: "0.2.17") + builder.bazel_dep( + name: "bazel_skylib", + version: skylib.rawValue) + builder.bazel_dep( + name: "apple_support", + version: appleSupport.rawValue) + builder.bazel_dep( + name: "rules_cc", + version: cc.rawValue) + builder.bazel_dep( + name: "rules_shell", + version: shell.rawValue) } } } diff --git a/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift b/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift new file mode 100644 index 0000000..efb0980 --- /dev/null +++ b/Sources/BazelizeKit/Bazel/Bazel+PrebuiltBUILD.swift @@ -0,0 +1,118 @@ +import BazelRules +import PathKit +import Starlark +import Xcode + +extension Bazel { + struct PrebuiltBuild: BazelFile { + let path: Path + public let builder = CodeBuilder() + init(_ root: Path) { + path = root + "Prebuilt" + "BUILD" + } + + var code: String { + builder.build() + } + + mutating func setup(_ kit: Kit) { + let imported = kit.project.targets + .flatMap(\.files.frameworks) + .filter { file in + guard file.label?.hasPrefix("//Prebuilt:") == true else { return false } + /// A binary that only exists after a bootstrap script has run is + /// not importable, and declaring it leaves the workspace + /// unloadable — iina references dylibs it builds separately. + guard let fullPath = file.fullPath else { return false } + return Path(fullPath).exists + } + + let frameworks = imported.filter { file in + file.fileType == "wrapper.framework" + } + + let xcframeworks = imported.filter { file in + file.fileType == "wrapper.xcframework" + } + + let staticLibraries = imported.filter { file in + file.fileType == "archive.ar" + } + + let dynamicLibraries = imported.filter { file in + file.fileType == "compiled.mach-o.dylib" + } + + buildFrameworks(frameworks) + buildXCFrameworks(xcframeworks) + buildLibraries(staticLibraries, dynamicLibraries) + } + + /// Checked-in `.a`/`.dylib` binaries; `cc_import` is the only rule that takes + /// a bare library and still exposes it to Swift and Objective-C targets. + private func buildLibraries(_ staticLibraries: [Xcode.File], _ dynamicLibraries: [Xcode.File]) { + guard !staticLibraries.isEmpty || !dynamicLibraries.isEmpty else { return } + builder.load(.cc_import) + + for file in unique(staticLibraries) { + guard let path = file.path, !path.isEmpty else { continue } + builder.call( + Rules.Cc.Call.cc_import( + name: Path(path).lastComponentWithoutExtension, + static_library: .named(Path(path).lastComponent), + visibility: .public)) + } + + for file in unique(dynamicLibraries) { + guard let path = file.path, !path.isEmpty else { continue } + builder.call( + Rules.Cc.Call.cc_import( + name: Path(path).lastComponentWithoutExtension, + shared_library: .named(Path(path).lastComponent), + visibility: .public)) + } + } + + private func buildXCFrameworks(_ files: [Xcode.File]) { + guard !files.isEmpty else { return } + builder.load(.apple_dynamic_xcframework_import) + + for file in unique(files) { + guard let path = file.path, !path.isEmpty else { continue } + let name = Path(path).lastComponentWithoutExtension + builder.call( + Rules.Apple.General.Call.apple_dynamic_xcframework_import( + name: name, + xcframework_imports: Starlark.glob([ + "\(Path(path).lastComponent)/**", + ]), + visibility: .public)) + } + } + + private func buildFrameworks(_ files: [Xcode.File]) { + guard !files.isEmpty else { return } + builder.load(.apple_dynamic_framework_import) + + for file in unique(files) { + guard let path = file.path, !path.isEmpty else { continue } + let name = Path(path).lastComponentWithoutExtension + builder.call( + Rules.Apple.General.Call.apple_dynamic_framework_import( + name: name, + framework_imports: Starlark.glob([ + "\(Path(path).lastComponent)/**", + ]), + visibility: .public)) + } + } + + private func unique(_ files: [Xcode.File]) -> [Xcode.File] { + var seen = Set() + return files.filter { file in + guard let path = file.path, !path.isEmpty else { return false } + return seen.insert(path).inserted + } + } + } +} diff --git a/Sources/BazelizeKit/Bazel/Bazel+RootBuild.swift b/Sources/BazelizeKit/Bazel/Bazel+RootBuild.swift index 0e1253e..c628e0f 100644 --- a/Sources/BazelizeKit/Bazel/Bazel+RootBuild.swift +++ b/Sources/BazelizeKit/Bazel/Bazel+RootBuild.swift @@ -9,7 +9,6 @@ import BazelRules import Foundation import PathKit import Starlark -import XCode extension Bazel { /// /BUILD diff --git a/Sources/BazelizeKit/Bazel/Bazel+TargetBUILD.swift b/Sources/BazelizeKit/Bazel/Bazel+TargetBUILD.swift index 2cfa760..f891c74 100644 --- a/Sources/BazelizeKit/Bazel/Bazel+TargetBUILD.swift +++ b/Sources/BazelizeKit/Bazel/Bazel+TargetBUILD.swift @@ -1,31 +1,22 @@ -// -// TargetBUILD.swift -// -// -// Created by Yume on 2022/12/8. -// - - import Foundation import PathKit import Starlark import Util -import XCode extension Bazel { /// /{TARGET}/BUILD struct TargetBuild: BazelFile { // MARK: Lifecycle - init(_ root: Path, _ target: XCode.Target) { + init(_ root: Path, _ target: Target) { self.target = target - targetPath = root + target.name + targetPath = root + "Targets" + target.name path = targetPath + "BUILD" } // MARK: Internal - let target: XCode.Target + let target: Target let path: Path let targetPath: Path diff --git a/Sources/BazelizeKit/Bazel/Bazel+Version.swift b/Sources/BazelizeKit/Bazel/Bazel+Version.swift new file mode 100644 index 0000000..ac91e17 --- /dev/null +++ b/Sources/BazelizeKit/Bazel/Bazel+Version.swift @@ -0,0 +1,15 @@ +import Foundation +import PathKit + +extension Bazel { + /// .bazelversion + struct Version: BazelFile { + let path: Path + let code = "\(Self.bazel.rawValue)" + static private let bazel: BazelDep.Bazel = .latest + + init(_ root: Path) { + path = root + ".bazelversion" + } + } +} diff --git a/Sources/BazelizeKit/Bazel/Bazel+WORKSPACE+Builder.swift b/Sources/BazelizeKit/Bazel/Bazel+WORKSPACE+Builder.swift deleted file mode 100644 index 3b23819..0000000 --- a/Sources/BazelizeKit/Bazel/Bazel+WORKSPACE+Builder.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// Workspace.swift -// -// -// Created by Yume on 2022/7/25. -// - -import Foundation - -extension Bazel.Workspace { - public struct Builder { - // MARK: Public - -// mutating -// public func `default`() { -// http_archive() -// rulesApple(repo: .v2_0_0) -// rulesSwift(repo: .v1_5_1) -// rulesPod(repo: .v4_1_0_412495) -// rulesSPM(repo: .v0_11_2) -// rulesSPM2() -// rulesXCodeProj(repo: .v0_11_0) -// rulesHammer(repo: .v3_4_3_3) -// } - - mutating - public func rulesPod(repo: Repo.Pod) { - _code = """ - # rules_pods - http_archive( - name = "rules_pods", - urls = ["https://github.com/pinterest/PodToBUILD/releases/download/\(repo.rawValue)/PodToBUILD.zip"], - # sha256 = "\(repo.sha256)", - ) - - load("@rules_pods//BazelExtensions:workspace.bzl", "new_pod_repository") - """ - } - - mutating - public func rulesSPM(repo: Repo.SPM) { - _code = """ - # rules_spm - http_archive( - name = "cgrindel_rules_spm", - # sha256 = "\(repo.sha256)", - strip_prefix = "rules_spm-\(repo.version)", - urls = [ - "http://github.com/cgrindel/rules_spm/archive/\(repo.rawValue).tar.gz", - ], - ) - - load( - "@cgrindel_rules_spm//spm:deps.bzl", - "spm_rules_dependencies", - ) - - spm_rules_dependencies() - """ - } - - mutating - public func rulesHammer(repo: Repo.Hammer) { - _code = """ - # rules_hammer - http_archive( - name = "xchammer", - urls = [ "https://github.com/pinterest/xchammer/releases/download/\(repo.rawValue)/xchammer.zip" ], - ) - """ - } - - mutating - public func custom(code: String) { - _code = code - } - - // MARK: Internal - - internal func build() -> String { - codes.joined(separator: "\n\n") - } - - // MARK: Private - - private var codes: [String] = [] - - - private var _code: String { - get { "" } - set { codes.append(newValue) } - } - } -} diff --git a/Sources/BazelizeKit/Bazel/Bazel+WORKSPACE.swift b/Sources/BazelizeKit/Bazel/Bazel+WORKSPACE.swift deleted file mode 100644 index 93aa04b..0000000 --- a/Sources/BazelizeKit/Bazel/Bazel+WORKSPACE.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// WORKSPACE.swift -// -// -// Created by Yume on 2022/4/27. -// - -import Foundation -import PathKit -import Util - -extension Bazel { - /// /WORKSPACE - struct Workspace: BazelFile { - let path: Path - public let builder = CodeBuilder() - - init(_ root: Path) { - path = root + "WORKSPACE" - } - - var code: String { - builder.build() - } - } -} diff --git a/Sources/BazelizeKit/Bazel/CodeBuilder.swift b/Sources/BazelizeKit/Bazel/CodeBuilder.swift index 0833abc..48e2408 100644 --- a/Sources/BazelizeKit/Bazel/CodeBuilder.swift +++ b/Sources/BazelizeKit/Bazel/CodeBuilder.swift @@ -36,6 +36,10 @@ extension CodeBuilder { load(loadableRule: rule) } + func load(_ rule: Rules.Cc) { + load(loadableRule: rule) + } + func load(_ rule: Rules.Apple.IOS) { load(loadableRule: rule) } diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+Apple.swift b/Sources/BazelizeKit/BazelDep/BazelDep+Apple.swift new file mode 100644 index 0000000..bde93f8 --- /dev/null +++ b/Sources/BazelizeKit/BazelDep/BazelDep+Apple.swift @@ -0,0 +1,64 @@ +extension BazelDep { + /// https://github.com/bazelbuild/rules_apple + enum Apple: String { + static let latest: Apple = .v5_0_0 + + case v5_0_0 = "5.0.0" + case v4_5_3 = "4.5.3" + case v4_5_2 = "4.5.2" + case v4_5_1 = "4.5.1" + case v4_5_0 = "4.5.0" + case v4_4_0 = "4.4.0" + case v4_3_3 = "4.3.3" + case v4_3_2 = "4.3.2" + case v4_3_1 = "4.3.1" + case v4_2_0 = "4.2.0" + case v4_1_2 = "4.1.2" + case v4_1_1 = "4.1.1" + case v4_1_0 = "4.1.0" + case v4_0_1 = "4.0.1" + case v4_0_0 = "4.0.0" + case v3_22_0 = "3.22.0" + case v3_21_1 = "3.21.1" + case v3_21_0 = "3.21.0" + case v3_20_1 = "3.20.1" + case v3_20_0 = "3.20.0" + case v3_19_1 = "3.19.1" + case v3_19_0 = "3.19.0" + case v3_18_0 = "3.18.0" + case v3_17_1 = "3.17.1" + case v3_17_0 = "3.17.0" + case v3_16_1 = "3.16.1" + case v3_16_0 = "3.16.0" + case v3_15_0 = "3.15.0" + case v3_14_0 = "3.14.0" + case v3_13_0 = "3.13.0" + case v3_12_0 = "3.12.0" + case v3_11_2 = "3.11.2" + case v3_11_1 = "3.11.1" + case v3_11_0 = "3.11.0" + case v3_10_0 = "3.10.0" + case v3_9_2 = "3.9.2" + case v3_9_1 = "3.9.1" + case v3_9_0 = "3.9.0" + case v3_8_0 = "3.8.0" + case v3_7_0 = "3.7.0" + case v3_6_0 = "3.6.0" + case v3_5_1 = "3.5.1" + case v3_5_0 = "3.5.0" + case v3_4_0 = "3.4.0" + case v3_3_0 = "3.3.0" + case v3_2_1 = "3.2.1" + case v3_2_0 = "3.2.0" + case v3_1_1 = "3.1.1" + case v3_1_0 = "3.1.0" + case v3_0_0 = "3.0.0" + case v2_5_0 = "2.5.0" + case v2_4_1 = "2.4.1" + case v2_4_0 = "2.4.0" + case v2_3_0 = "2.3.0" + case v2_2_0 = "2.2.0" + case v2_1_0 = "2.1.0" + case v2_0_0 = "2.0.0" + } +} \ No newline at end of file diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+AppleLinker.swift b/Sources/BazelizeKit/BazelDep/BazelDep+AppleLinker.swift new file mode 100644 index 0000000..99009ed --- /dev/null +++ b/Sources/BazelizeKit/BazelDep/BazelDep+AppleLinker.swift @@ -0,0 +1,18 @@ +extension BazelDep { + /// https://github.com/keith/rules_apple_linker + enum AppleLinker: String { + static let latest: AppleLinker = .v0_7_0 + + case v0_7_0 = "0.7.0" + case v0_6_3 = "0.6.3" + case v0_6_2 = "0.6.2" + case v0_5_4 = "0.5.4" + case v0_5_3 = "0.5.3" + case v0_5_2 = "0.5.2" + case v0_5_1 = "0.5.1" + case v0_5_0 = "0.5.0" + case v0_4_0 = "0.4.0" + case v0_3_1 = "0.3.1" + case v0_3_0 = "0.3.0" + } +} \ No newline at end of file diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+AppleSupport.swift b/Sources/BazelizeKit/BazelDep/BazelDep+AppleSupport.swift new file mode 100644 index 0000000..2562621 --- /dev/null +++ b/Sources/BazelizeKit/BazelDep/BazelDep+AppleSupport.swift @@ -0,0 +1,63 @@ +extension BazelDep { + /// https://github.com/bazelbuild/apple_support + enum AppleSupport: String { + static let latest: AppleSupport = .v2_8_2 + + case v2_8_2 = "2.8.2" + case v2_8_1 = "2.8.1" + case v2_8_0 = "2.8.0" + case v2_7_0 = "2.7.0" + case v2_6_1 = "2.6.1" + case v2_5_4 = "2.5.4" + case v2_5_3 = "2.5.3" + case v2_5_2 = "2.5.2" + case v2_5_1 = "2.5.1" + case v2_5_0 = "2.5.0" + case v2_4_0 = "2.4.0" + case v2_3_0 = "2.3.0" + case v2_2_0 = "2.2.0" + case v2_1_0 = "2.1.0" + case v2_0_0 = "2.0.0" + case v1_24_5 = "1.24.5" + case v1_24_4 = "1.24.4" + case v1_24_3 = "1.24.3" + case v1_24_2 = "1.24.2" + case v1_24_1 = "1.24.1" + case v1_24_0 = "1.24.0" + case v1_23_1 = "1.23.1" + case v1_23_0 = "1.23.0" + case v1_22_1 = "1.22.1" + case v1_22_0 = "1.22.0" + case v1_21_1 = "1.21.1" + case v1_21_0 = "1.21.0" + case v1_20_0 = "1.20.0" + case v1_19_0 = "1.19.0" + case v1_18_1 = "1.18.1" + case v1_18_0 = "1.18.0" + case v1_17_1 = "1.17.1" + case v1_17_0 = "1.17.0" + case v1_16_0 = "1.16.0" + case v1_15_1 = "1.15.1" + case v1_14_0 = "1.14.0" + case v1_13_0 = "1.13.0" + case v1_12_0 = "1.12.0" + case v1_11_1 = "1.11.1" + case v1_11_0 = "1.11.0" + case v1_10_1 = "1.10.1" + case v1_10_0 = "1.10.0" + case v1_9_0 = "1.9.0" + case v1_8_1 = "1.8.1" + case v1_8_0 = "1.8.0" + case v1_7_1 = "1.7.1" + case v1_7_0 = "1.7.0" + case v1_6_0 = "1.6.0" + case v1_5_0 = "1.5.0" + case v1_4_1 = "1.4.1" + case v1_4_0 = "1.4.0" + case v1_3_2 = "1.3.2" + case v1_3_1 = "1.3.1" + case v1_0_0 = "1.0.0" + case v0_13_0 = "0.13.0" + case v0_11_0 = "0.11.0" + } +} \ No newline at end of file diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+Bazel.swift b/Sources/BazelizeKit/BazelDep/BazelDep+Bazel.swift new file mode 100644 index 0000000..2a63037 --- /dev/null +++ b/Sources/BazelizeKit/BazelDep/BazelDep+Bazel.swift @@ -0,0 +1,171 @@ +extension BazelDep { + /// https://github.com/bazelbuild/bazel + enum Bazel: String { + static let latest: Bazel = .v9_2_0 + + case v9_2_0 = "9.2.0" + case v9_1_1 = "9.1.1" + case v9_1_0 = "9.1.0" + case v9_0_2 = "9.0.2" + case v9_0_1 = "9.0.1" + case v9_0_0 = "9.0.0" + case v8_8_0 = "8.8.0" + case v8_7_0 = "8.7.0" + case v8_6_0 = "8.6.0" + case v8_5_1 = "8.5.1" + case v8_5_0 = "8.5.0" + case v8_4_2 = "8.4.2" + case v8_4_1 = "8.4.1" + case v8_4_0 = "8.4.0" + case v8_3_1 = "8.3.1" + case v8_3_0 = "8.3.0" + case v8_2_1 = "8.2.1" + case v8_2_0 = "8.2.0" + case v8_1_1 = "8.1.1" + case v8_1_0 = "8.1.0" + case v8_0_1 = "8.0.1" + case v8_0_0 = "8.0.0" + case v7_7_1 = "7.7.1" + case v7_7_0 = "7.7.0" + case v7_6_2 = "7.6.2" + case v7_6_1 = "7.6.1" + case v7_6_0 = "7.6.0" + case v7_5_0 = "7.5.0" + case v7_4_1 = "7.4.1" + case v7_4_0 = "7.4.0" + case v7_3_2 = "7.3.2" + case v7_3_1 = "7.3.1" + case v7_3_0 = "7.3.0" + case v7_2_1 = "7.2.1" + case v7_2_0 = "7.2.0" + case v7_1_2 = "7.1.2" + case v7_1_1 = "7.1.1" + case v7_1_0 = "7.1.0" + case v7_0_2 = "7.0.2" + case v7_0_1 = "7.0.1" + case v7_0_0 = "7.0.0" + case v6_6_0 = "6.6.0" + case v6_5_0 = "6.5.0" + case v6_4_0 = "6.4.0" + case v6_3_2 = "6.3.2" + case v6_3_1 = "6.3.1" + case v6_3_0 = "6.3.0" + case v6_2_1 = "6.2.1" + case v6_2_0 = "6.2.0" + case v6_1_2 = "6.1.2" + case v6_1_1 = "6.1.1" + case v6_1_0 = "6.1.0" + case v6_0_0 = "6.0.0" + case v5_4_1 = "5.4.1" + case v5_4_0 = "5.4.0" + case v5_3_2 = "5.3.2" + case v5_3_1 = "5.3.1" + case v5_3_0 = "5.3.0" + case v5_2_0 = "5.2.0" + case v5_1_1 = "5.1.1" + case v5_1_0 = "5.1.0" + case v5_0_0 = "5.0.0" + case v4_2_4 = "4.2.4" + case v4_2_3 = "4.2.3" + case v4_2_2 = "4.2.2" + case v4_2_1 = "4.2.1" + case v4_2_0 = "4.2.0" + case v4_1_0 = "4.1.0" + case v4_0_0 = "4.0.0" + case v3_7_2 = "3.7.2" + case v3_7_1 = "3.7.1" + case v3_7_0 = "3.7.0" + case v3_6_0 = "3.6.0" + case v3_5_1 = "3.5.1" + case v3_5_0 = "3.5.0" + case v3_4_1 = "3.4.1" + case v3_4_0 = "3.4.0" + case v3_3_1 = "3.3.1" + case v3_3_0 = "3.3.0" + case v3_2_0 = "3.2.0" + case v3_1_0 = "3.1.0" + case v3_0_0 = "3.0.0" + case v2_2_0 = "2.2.0" + case v2_1_1 = "2.1.1" + case v2_1_0 = "2.1.0" + case v2_0_1 = "2.0.1" + case v2_0_0 = "2.0.0" + case v1_2_1 = "1.2.1" + case v1_2_0 = "1.2.0" + case v1_1_0 = "1.1.0" + case v1_0_1 = "1.0.1" + case v1_0_0 = "1.0.0" + case v0_29_1 = "0.29.1" + case v0_29_0 = "0.29.0" + case v0_28_1 = "0.28.1" + case v0_28_0 = "0.28.0" + case v0_27_2 = "0.27.2" + case v0_27_1 = "0.27.1" + case v0_27_0 = "0.27.0" + case v0_26_1 = "0.26.1" + case v0_26_0 = "0.26.0" + case v0_25_3 = "0.25.3" + case v0_25_2 = "0.25.2" + case v0_25_1 = "0.25.1" + case v0_25_0 = "0.25.0" + case v0_24_1 = "0.24.1" + case v0_24_0 = "0.24.0" + case v0_23_2 = "0.23.2" + case v0_23_1 = "0.23.1" + case v0_23_0 = "0.23.0" + case v0_22_0 = "0.22.0" + case v0_21_0 = "0.21.0" + case v0_20_0 = "0.20.0" + case v0_19_2 = "0.19.2" + case v0_19_1 = "0.19.1" + case v0_19_0 = "0.19.0" + case v0_18_1 = "0.18.1" + case v0_18_0 = "0.18.0" + case v0_17_2 = "0.17.2" + case v0_17_1 = "0.17.1" + case v0_16_1 = "0.16.1" + case v0_16_0 = "0.16.0" + case v0_15_2 = "0.15.2" + case v0_15_1 = "0.15.1" + case v0_15_0 = "0.15.0" + case v0_14_1 = "0.14.1" + case v0_14_0 = "0.14.0" + case v0_13_1 = "0.13.1" + case v0_13_0 = "0.13.0" + case v0_12_0 = "0.12.0" + case v0_11_1 = "0.11.1" + case v0_11_0 = "0.11.0" + case v0_10_1 = "0.10.1" + case v0_10_0 = "0.10.0" + case v0_9_0 = "0.9.0" + case v0_8_1 = "0.8.1" + case v0_8_0 = "0.8.0" + case v0_7_0 = "0.7.0" + case v0_6_1 = "0.6.1" + case v0_6_0 = "0.6.0" + case v0_5_4 = "0.5.4" + case v0_5_3 = "0.5.3" + case v0_5_2 = "0.5.2" + case v0_5_1 = "0.5.1" + case v0_5_0 = "0.5.0" + case v0_4_5 = "0.4.5" + case v0_4_4 = "0.4.4" + case v0_4_3 = "0.4.3" + case v0_4_2 = "0.4.2" + case v0_4_1 = "0.4.1" + case v0_4_0 = "0.4.0" + case v0_3_2 = "0.3.2" + case v0_3_1 = "0.3.1" + case v0_3_0 = "0.3.0" + case v0_2_3 = "0.2.3" + case v0_2_2 = "0.2.2" + case v0_2_1 = "0.2.1" + case v0_2_0 = "0.2.0" + case v0_1_5 = "0.1.5" + case v0_1_4 = "0.1.4" + case v0_1_3 = "0.1.3" + case v0_1_2 = "0.1.2" + case v0_1_1 = "0.1.1" + case v0_1_0 = "0.1.0" + } +} \ No newline at end of file diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+BazelSkylib.swift b/Sources/BazelizeKit/BazelDep/BazelDep+BazelSkylib.swift new file mode 100644 index 0000000..d1a3275 --- /dev/null +++ b/Sources/BazelizeKit/BazelDep/BazelDep+BazelSkylib.swift @@ -0,0 +1,25 @@ +extension BazelDep { + /// https://github.com/bazelbuild/bazel-skylib + enum BazelSkylib: String { + static let latest: BazelSkylib = .v1_9_2 + + case v1_9_2 = "1.9.2" + case v1_9_0 = "1.9.0" + case v1_8_2 = "1.8.2" + case v1_8_1 = "1.8.1" + case v1_8_0 = "1.8.0" + case v1_7_1 = "1.7.1" + case v1_7_0 = "1.7.0" + case v1_6_1 = "1.6.1" + case v1_6_0 = "1.6.0" + case v1_5_0 = "1.5.0" + case v1_4_2 = "1.4.2" + case v1_4_1 = "1.4.1" + case v1_4_0 = "1.4.0" + case v1_3_0 = "1.3.0" + case v1_2_1 = "1.2.1" + case v1_2_0 = "1.2.0" + case v1_1_1 = "1.1.1" + case v1_0_3 = "1.0.3" + } +} \ No newline at end of file diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+RulesCC.swift b/Sources/BazelizeKit/BazelDep/BazelDep+RulesCC.swift new file mode 100644 index 0000000..76dc399 --- /dev/null +++ b/Sources/BazelizeKit/BazelDep/BazelDep+RulesCC.swift @@ -0,0 +1,49 @@ +extension BazelDep { + /// https://github.com/bazelbuild/rules_cc + enum RulesCC: String { + static let latest: RulesCC = .v0_2_22 + + case v0_2_22 = "0.2.22" + case v0_2_21 = "0.2.21" + case v0_2_20 = "0.2.20" + case v0_2_19 = "0.2.19" + case v0_2_18 = "0.2.18" + case v0_2_17 = "0.2.17" + case v0_2_16 = "0.2.16" + case v0_2_15 = "0.2.15" + case v0_2_14 = "0.2.14" + case v0_2_13 = "0.2.13" + case v0_2_12 = "0.2.12" + case v0_2_11 = "0.2.11" + case v0_2_10 = "0.2.10" + case v0_2_9 = "0.2.9" + case v0_2_8 = "0.2.8" + case v0_2_7 = "0.2.7" + case v0_2_6 = "0.2.6" + case v0_2_5 = "0.2.5" + case v0_2_4 = "0.2.4" + case v0_2_3 = "0.2.3" + case v0_2_2 = "0.2.2" + case v0_2_1 = "0.2.1" + case v0_2_0 = "0.2.0" + case v0_1_5 = "0.1.5" + case v0_1_4 = "0.1.4" + case v0_1_3 = "0.1.3" + case v0_1_2 = "0.1.2" + case v0_1_1 = "0.1.1" + case v0_0_17 = "0.0.17" + case v0_0_16 = "0.0.16" + case v0_0_15 = "0.0.15" + case v0_0_13 = "0.0.13" + case v0_0_12 = "0.0.12" + case v0_0_11 = "0.0.11" + case v0_0_10 = "0.0.10" + case v0_0_9 = "0.0.9" + case v0_0_8 = "0.0.8" + case v0_0_6 = "0.0.6" + case v0_0_5 = "0.0.5" + case v0_0_4 = "0.0.4" + case v0_0_2 = "0.0.2" + case v0_0_1 = "0.0.1" + } +} \ No newline at end of file diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+RulesShell.swift b/Sources/BazelizeKit/BazelDep/BazelDep+RulesShell.swift new file mode 100644 index 0000000..9942abe --- /dev/null +++ b/Sources/BazelizeKit/BazelDep/BazelDep+RulesShell.swift @@ -0,0 +1,18 @@ +extension BazelDep { + /// https://github.com/bazelbuild/rules_shell + enum RulesShell: String { + static let latest: RulesShell = .v0_8_0 + + case v0_8_0 = "0.8.0" + case v0_7_1 = "0.7.1" + case v0_6_1 = "0.6.1" + case v0_6_0 = "0.6.0" + case v0_5_1 = "0.5.1" + case v0_5_0 = "0.5.0" + case v0_4_1 = "0.4.1" + case v0_4_0 = "0.4.0" + case v0_3_0 = "0.3.0" + case v0_2_0 = "0.2.0" + case v0_1_0 = "0.1.0" + } +} diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+Swift.swift b/Sources/BazelizeKit/BazelDep/BazelDep+Swift.swift new file mode 100644 index 0000000..6933acf --- /dev/null +++ b/Sources/BazelizeKit/BazelDep/BazelDep+Swift.swift @@ -0,0 +1,58 @@ +extension BazelDep { + /// https://github.com/bazelbuild/rules_swift + enum Swift: String { + static let latest: Swift = .v4_0_1 + + case v4_0_1 = "4.0.1" + case v3_6_1 = "3.6.1" + case v3_6_0 = "3.6.0" + case v3_5_0 = "3.5.0" + case v3_4_2 = "3.4.2" + case v3_4_1 = "3.4.1" + case v3_4_0 = "3.4.0" + case v3_3_0 = "3.3.0" + case v3_2_0 = "3.2.0" + case v3_1_2 = "3.1.2" + case v3_1_1 = "3.1.1" + case v3_1_0 = "3.1.0" + case v3_0_2 = "3.0.2" + case v2_9_0 = "2.9.0" + case v2_8_2 = "2.8.2" + case v2_8_1 = "2.8.1" + case v2_8_0 = "2.8.0" + case v2_7_0 = "2.7.0" + case v2_6_0 = "2.6.0" + case v2_5_0 = "2.5.0" + case v2_4_0 = "2.4.0" + case v2_3_1 = "2.3.1" + case v2_3_0 = "2.3.0" + case v2_2_4 = "2.2.4" + case v2_2_3 = "2.2.3" + case v2_2_2 = "2.2.2" + case v2_2_1 = "2.2.1" + case v2_2_0 = "2.2.0" + case v2_1_1 = "2.1.1" + case v2_1_0 = "2.1.0" + case v2_0_0 = "2.0.0" + case v1_18_0 = "1.18.0" + case v1_17_0 = "1.17.0" + case v1_16_0 = "1.16.0" + case v1_15_1 = "1.15.1" + case v1_15_0 = "1.15.0" + case v1_14_0 = "1.14.0" + case v1_13_0 = "1.13.0" + case v1_12_0 = "1.12.0" + case v1_11_0 = "1.11.0" + case v1_10_1 = "1.10.1" + case v1_10_0 = "1.10.0" + case v1_9_1 = "1.9.1" + case v1_9_0 = "1.9.0" + case v1_8_0 = "1.8.0" + case v1_7_1 = "1.7.1" + case v1_7_0 = "1.7.0" + case v1_6_0 = "1.6.0" + case v1_5_1 = "1.5.1" + case v1_5_0 = "1.5.0" + case v1_2_0 = "1.2.0" + } +} \ No newline at end of file diff --git a/Sources/BazelizeKit/BazelDep/BazelDep+XcodeProj.swift b/Sources/BazelizeKit/BazelDep/BazelDep+XcodeProj.swift new file mode 100644 index 0000000..7306c9a --- /dev/null +++ b/Sources/BazelizeKit/BazelDep/BazelDep+XcodeProj.swift @@ -0,0 +1,76 @@ +extension BazelDep { + /// https://github.com/MobileNativeFoundation/rules_xcodeproj + enum XcodeProj: String { + static let latest: XcodeProj = .v4_1_0 + + case v4_1_0 = "4.1.0" + case v4_0_1 = "4.0.1" + case v4_0_0 = "4.0.0" + case v3_6_0 = "3.6.0" + case v3_5_1 = "3.5.1" + case v3_4_1 = "3.4.1" + case v3_4_0 = "3.4.0" + case v3_3_0 = "3.3.0" + case v3_2_0 = "3.2.0" + case v3_1_2 = "3.1.2" + case v3_1_0 = "3.1.0" + case v3_0_0 = "3.0.0" + case v2_12_1 = "2.12.1" + case v2_12_0 = "2.12.0" + case v2_11_2 = "2.11.2" + case v2_11_1 = "2.11.1" + case v2_11_0 = "2.11.0" + case v2_10_0 = "2.10.0" + case v2_9_2 = "2.9.2" + case v2_9_1 = "2.9.1" + case v2_9_0 = "2.9.0" + case v2_8_1 = "2.8.1" + case v2_8_0 = "2.8.0" + case v2_7_0 = "2.7.0" + case v2_6_1 = "2.6.1" + case v2_6_0 = "2.6.0" + case v2_5_2 = "2.5.2" + case v2_5_1 = "2.5.1" + case v2_5_0 = "2.5.0" + case v2_4_0 = "2.4.0" + case v2_3_1 = "2.3.1" + case v2_3_0 = "2.3.0" + case v2_2_0 = "2.2.0" + case v2_1_1 = "2.1.1" + case v2_1_0 = "2.1.0" + case v2_0_0 = "2.0.0" + case v1_18_0 = "1.18.0" + case v1_17_0 = "1.17.0" + case v1_16_0 = "1.16.0" + case v1_15_0 = "1.15.0" + case v1_14_2 = "1.14.2" + case v1_14_1 = "1.14.1" + case v1_14_0 = "1.14.0" + case v1_13_0 = "1.13.0" + case v1_12_1 = "1.12.1" + case v1_12_0 = "1.12.0" + case v1_11_0 = "1.11.0" + case v1_10_1 = "1.10.1" + case v1_10_0 = "1.10.0" + case v1_9_1 = "1.9.1" + case v1_9_0 = "1.9.0" + case v1_8_1 = "1.8.1" + case v1_8_0 = "1.8.0" + case v1_7_1 = "1.7.1" + case v1_7_0 = "1.7.0" + case v1_6_0 = "1.6.0" + case v1_5_1 = "1.5.1" + case v1_5_0 = "1.5.0" + case v1_4_0 = "1.4.0" + case v1_3_3 = "1.3.3" + case v1_3_2 = "1.3.2" + case v1_3_1 = "1.3.1" + case v1_3_0 = "1.3.0" + case v1_2_0 = "1.2.0" + case v1_1_0 = "1.1.0" + case v1_0_1 = "1.0.1" + case v0_12_3 = "0.12.3" + case v0_12_2 = "0.12.2" + case v0_12_0 = "0.12.0" + } +} \ No newline at end of file diff --git a/Sources/BazelizeKit/BazelDep/BazelDep.swift b/Sources/BazelizeKit/BazelDep/BazelDep.swift new file mode 100644 index 0000000..8cc8671 --- /dev/null +++ b/Sources/BazelizeKit/BazelDep/BazelDep.swift @@ -0,0 +1,13 @@ +// +// BazelDep.swift +// +// +// Created by Yume on 2022/7/5. +// + +// MARK: - BazelDep + +/// Released versions of the Bazel modules Bazelize emits into `MODULE.bazel`. +/// +/// Generated by the `repo-enum` package plugin; see `RepoSources.yml`. +enum BazelDep { } diff --git a/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift new file mode 100644 index 0000000..250b429 --- /dev/null +++ b/Sources/BazelizeKit/Codegen/CodeGen+Extension.swift @@ -0,0 +1,112 @@ +// +// File.swift +// Bazelize +// +// Created by 林煒峻 on 2026/4/30. +// + +import Foundation + +extension Target { + // MARK: Internal + + func generateExtension(_ builder: CodeBuilder, _ kit: Kit) { + switch platformSDK { + case .iOS: buildIOS(builder, kit) + case .macOS: buildMac(builder, kit) + default: break + } + } + + /// An XPC service is its own bundle inside `Contents/XPCServices`, which the + /// application's copy phase puts it in. + func generateXPCService(_ builder: CodeBuilder, _ kit: Kit) { + let project = kit.project + builder.load(.macos_xpc_service) + builder.call( + Rules.Apple.MacOS.Call.macos_xpc_service( + name: name, + additional_contents: additionalContents(project: project), + bundle_id: bundleIdentifier(project: project), + deps: .build { + ":\(name)_library" + }, + entitlements: entitlementsLabel(project: project), + infoplists: .build { + plistFile(kit) + plist_auto + plistDefault(kit) + }, + minimum_os_version: prefer(\.platform.macOS), + resources: .build { + bundleResources(project: project) + }, + strings: .build { + if !allStrings.isEmpty { + ":Strings" + } + }, + visibility: .public)) + } + + private func buildMac(_ builder: CodeBuilder, _ kit: Kit) { + let project = kit.project + builder.load(.macos_extension) + builder.call( + Rules.Apple.MacOS.Call.macos_extension( + name: name, + additional_contents: additionalContents(project: project), + bundle_id: bundleIdentifier(project: project), + deps: .build { + ":\(name)_library" + }, + entitlements: entitlementsLabel(project: project), + frameworks: embeddedFrameworks(project: project), + infoplists: .build { + plistFile(kit) + plist_auto + plistDefault(kit) + }, + minimum_os_version: prefer(\.platform.macOS), + resources: .build { + bundleResources(project: project) + }, + strings: .build { + if !allStrings.isEmpty { + ":Strings" + } + }, + visibility: .public)) + } + + private func buildIOS(_ builder: CodeBuilder, _ kit: Kit) { + builder.load(.ios_extension) + // families = ["iphone", "ipad"], + // provisioning_profile = ":ShareExtension.mobileprovision", # 若需要簽名 + builder.call( + Rules.Apple.IOS.Call.ios_extension( + name: name, + bundle_id: bundleIdentifier(project: kit.project), + deps: .build { + ":\(name)_library" + frameworks + }, + entitlements: entitlementsLabel(project: kit.project), + families: deviceFamilies, + infoplists: .build { + plistFile(kit) + plist_auto + plistDefault(kit) + }, + minimum_os_version: prefer(\.platform.iOS), + resources: .build { + bundleResources(project: kit.project) + }, + strings: .build { + if !allStrings.isEmpty { + ":Strings" + } + }, + visibility: .public)) + } +} diff --git a/Sources/BazelizeKit/Codegen/Codegen+Application.swift b/Sources/BazelizeKit/Codegen/Codegen+Application.swift index b6fd227..0113813 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Application.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Application.swift @@ -1,97 +1,102 @@ -// -// Codegen+Application.swift -// -// -// Created by Yume on 2022/4/29. -// - -import BazelRules -import Foundation -import PathKit -import Starlark -import XCode - +import Util extension Target { // MARK: Internal func generateApplicationCode(_ builder: CodeBuilder, _ kit: Kit) { - switch prefer(\.sdk) { + switch platformSDK { case .iOS: buildIOS(builder, kit) case .macOS: buildMac(builder, kit) case .tvOS: buildTV(builder, kit) case .watchOS: buildWatch(builder, kit) case .auto: - let family = prefer(\.deviceFamily) + let family = prefer(\.platform.deviceFamily) guard let family else { return } if family.contains(.iphone) { buildIOS(builder, kit) } - default: break + default: + Log.codeGenerate.warning(""" + Name: \(name, privacy: .public) + SDK: \(platformSDK?.rawValue ?? "nil", privacy: .public) has no application rule + """) } } - func generateCommandLineApplicationCode(_ builder: CodeBuilder, _: Kit) { + func generateCommandLineApplicationCode(_ builder: CodeBuilder, _ kit: Kit) { builder.load(.macos_command_line_application) builder.call( Rules.Apple.MacOS.Call.macos_command_line_application( name: name, - bundle_id: prefer(\.bundleID), + bundle_id: bundleIdentifier(project: kit.project), deps: .build { ":\(name)_library" }, infoplists: .build { - plist_file + plistFile(kit) plist_auto - // plist_default + plistDefault(kit) }, - minimum_os_version: prefer(\.macOS), + minimum_os_version: prefer(\.platform.macOS), visibility: .public)) } // MARK: Private - private func buildWatch(_ builder: CodeBuilder, _: Kit) { + private func buildWatch(_ builder: CodeBuilder, _ kit: Kit) { builder.load(.watchos_application) builder.call( Rules.Apple.WatchOS.Call.watchos_application( name: name, - bundle_id: prefer(\.bundleID), + bundle_id: bundleIdentifier(project: kit.project), deps: .build { ":\(name)_library" frameworks }, infoplists: .build { - plist_file + plistFile(kit) plist_auto - plist_default + plistDefault(kit) }, - minimum_os_version: prefer(\.watchOS), + minimum_os_version: prefer(\.platform.watchOS), resources: .build { - resources + bundleResources(project: kit.project) + }, + strings: .build { + if !allStrings.isEmpty { + ":Strings" + } }, visibility: .public)) } - private func buildIOS(_ builder: CodeBuilder, _: Kit) { + private func buildIOS(_ builder: CodeBuilder, _ kit: Kit) { + let project = kit.project builder.load(.ios_application) builder.call( Rules.Apple.IOS.Call.ios_application( name: name, - bundle_id: prefer(\.bundleID), + app_icons: appIcons(project: kit.project), + bundle_id: bundleIdentifier(project: kit.project), deps: .build { ":\(name)_library" - frameworks + linkedFrameworks(project: project) }, - families: prefer(\.deviceFamily)?.map(\.code), + entitlements: entitlementsLabel(project: project), + extensions: embeddedExtensions(project: project), + frameworks: embeddedFrameworks(project: project), + families: deviceFamilies, infoplists: .build { - plist_file + plistFile(kit) plist_auto - plist_default + plistDefault(kit) }, // "launch_storyboard" => ":Base.lproj/LaunchScreen.storyboard" - minimum_os_version: prefer(\.iOS), + minimum_os_version: prefer(\.platform.iOS), + resources: .build { + bundleResources(project: project) + }, sdk_frameworks: frameworksSDK, strings: .build { if !allStrings.isEmpty { @@ -101,43 +106,78 @@ extension Target { visibility: .public)) } - private func buildMac(_ builder: CodeBuilder, _: Kit) { + private func buildMac(_ builder: CodeBuilder, _ kit: Kit) { builder.load(.macos_application) builder.call( Rules.Apple.MacOS.Call.macos_application( name: name, - bundle_id: prefer(\.bundleID), + additional_contents: additionalContents(project: kit.project), + app_icons: appIcons(project: kit.project), + bundle_id: bundleIdentifier(project: kit.project), deps: .build { ":\(name)_library" }, + entitlements: entitlementsLabel(project: kit.project), + extensions: embeddedExtensions(project: kit.project), + frameworks: embeddedFrameworks(project: kit.project), infoplists: .build { - plist_file + plistFile(kit) plist_auto - plist_default + plistDefault(kit) + }, + minimum_os_version: prefer(\.platform.macOS), + resources: .build { + bundleResources(project: kit.project) + }, + strings: .build { + if !allStrings.isEmpty { + ":Strings" + } }, - minimum_os_version: prefer(\.macOS), visibility: .public)) } - private func buildTV(_ builder: CodeBuilder, _: Kit) { + private func buildTV(_ builder: CodeBuilder, _ kit: Kit) { builder.load(.tvos_application) builder.call( Rules.Apple.TVOS.Call.tvos_application( name: name, - bundle_id: prefer(\.bundleID), + bundle_id: bundleIdentifier(project: kit.project), deps: .build { ":\(name)_library" frameworks }, infoplists: .build { - plist_file + plistFile(kit) plist_auto - plist_default + plistDefault(kit) }, - minimum_os_version: prefer(\.tvOS), + minimum_os_version: prefer(\.platform.tvOS), resources: .build { - resources + bundleResources(project: kit.project) + }, + strings: .build { + if !allStrings.isEmpty { + ":Strings" + } }, visibility: .public)) } + + /// `app_icons` globs would fail analysis on a catalog without the icon set + /// (`glob` disallows empty matches), and targets commonly carry several + /// catalogs — SwiftUI previews add one. + func appIcons(project: Project?) -> Starlark.Value? { + guard let project, let iconName = prefer(\.assetCatalog.appIconName) else { return nil } + + let workspace = Path(project.workspacePath) + let iconGlobs = assets.compactMap { asset -> String? in + let relative = asset.delete(prefix: "Sources/") ?? asset + guard (workspace + relative + "\(iconName).appiconset").exists else { return nil } + return "\(asset)/\(iconName).appiconset/**" + } + + return iconGlobs.isEmpty ? nil : Starlark.glob(iconGlobs) + } + } diff --git a/Sources/BazelizeKit/Codegen/Codegen+Autolink.swift b/Sources/BazelizeKit/Codegen/Codegen+Autolink.swift new file mode 100644 index 0000000..d2e29d5 --- /dev/null +++ b/Sources/BazelizeKit/Codegen/Codegen+Autolink.swift @@ -0,0 +1,124 @@ +import Foundation +import PathKit +import Util +import Xcode + +extension Target { + /// What the rule declares: the frameworks the project links plus the ones its + /// Objective-C sources import. + func sdkFrameworks(project: Project) -> [String]? { + let all = Set(frameworksSDK).union(autolinkedFrameworks(project: project)) + .subtracting(weakFrameworksSDK) + return all.isEmpty ? nil : all.sorted() + } + + /// SDK frameworks the Objective-C half imports as modules. + /// + /// Xcode links them without anyone declaring them: clang records an autolink + /// directive for every framework module it imports. Bazel compiles with + /// `-fno-autolink` — it wants the dependency declared — so the imports are read + /// out of the sources instead, exactly the set clang would have recorded. + func autolinkedFrameworks(project: Project) -> [String] { + let available = SDKFrameworks.names(for: platformSDK) + guard !available.isEmpty else { return [] } + + let workspace = Path(project.workspacePath) + let sources = srcs_c + srcs_objc + srcs_cpp + srcs_objcpp + + moduleHeaderFiles(project: project) + internalHeaderFiles(project: project) + + var result = Set() + + for source in sources { + let path = workspace + Path(source.delete(prefix: "Sources/") ?? source) + guard let content: String = try? path.read() else { continue } + + for name in content.importedModuleNames where available.contains(name) { + result.insert(name) + } + } + + return result.sorted() + } +} + +/// The frameworks an SDK ships, which is what makes an import autolinkable. +private enum SDKFrameworks { + static func names(for platform: SDK?) -> Set { + let sdk = sdkName(for: platform) + + if let cached = cache[sdk] { + return cached + } + + let names = read(sdk: sdk) + cache[sdk] = names + return names + } + + private nonisolated(unsafe) static var cache: [String: Set] = [:] + + private static func sdkName(for platform: SDK?) -> String { + switch platform { + case .iOS: return "iphoneos" + case .tvOS: return "appletvos" + case .watchOS: return "watchos" + default: return "macosx" + } + } + + private static func read(sdk: String) -> Set { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["--sdk", sdk, "--show-sdk-path"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + guard (try? process.run()) != nil else { return [] } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard + let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !output.isEmpty + else { + return [] + } + + let roots = [ + Path(output) + "System/Library/Frameworks", + Path(output) + "System/iOSSupport/System/Library/Frameworks" + ] + + var names = Set() + for root in roots { + guard let children = try? root.children() else { continue } + for child in children where child.extension == "framework" { + names.insert(child.lastComponentWithoutExtension) + } + } + + return names + } +} + +extension String { + /// `@import Accelerate;`, `#import ` and the `#include` + /// spelling of the same. + fileprivate var importedModuleNames: [String] { + let patterns = [ + #"@import\s+([A-Za-z_][A-Za-z0-9_]*)"#, + #"#\s*(?:import|include)\s+<([A-Za-z_][A-Za-z0-9_]*)/"# + ] + + return patterns.flatMap { pattern -> [String] in + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + + return regex.matches(in: self, range: NSRange(startIndex..., in: self)).compactMap { match in + guard let range = Range(match.range(at: 1), in: self) else { return nil } + return String(self[range]) + } + } + } +} diff --git a/Sources/BazelizeKit/Codegen/Codegen+CopyFiles.swift b/Sources/BazelizeKit/Codegen/Codegen+CopyFiles.swift new file mode 100644 index 0000000..42f1528 --- /dev/null +++ b/Sources/BazelizeKit/Codegen/Codegen+CopyFiles.swift @@ -0,0 +1,278 @@ +import BazelRules +import Foundation +import PathKit +import Starlark +import Xcode + +extension Target { + /// Products Xcode copies into the bundle outside the framework and extension + /// phases: a login-item helper app, a privileged helper tool, an XPC service. + /// + /// rules_apple takes them as `additional_contents`, keyed by the subdirectory of + /// `Contents` they belong in, and knows how to place an app bundle, a bare + /// executable or a plain file. + func additionalContents(project: Project?) -> [String: String] { + var result = copiedProducts(project: project).reduce(into: [String: String]()) { result, copied in + result[copied.label] = copied.subdirectory + } + + for group in copiedFileGroups(project: project) where !group.isBundleResource { + result[":\(group.subdirectory.copyFilesRuleName)"] = group.subdirectory + } + + return result + } + + /// Files copied into `Resources` travel with the target's library as structured + /// resources, which keep the destination directory they are staged under and + /// reach whichever bundle links or embeds the library. + func generateCopiedResourceGroup(_ builder: CodeBuilder, _ kit: Kit) { + let prefix = "\(Self.copyFilesRoot)/Resources" + let sources = copiedFileGroups(project: kit.project) + .filter(\.isBundleResource) + .flatMap { group in + group.files.map { file in + "\(Self.copyFilesRoot)/\(group.subdirectory)/\(Path(file).lastComponent)" + } + } + + guard !sources.isEmpty else { return } + + builder.load(loadableRule: Rules.Apple.Resources.apple_resource_group) + builder.call( + Rules.Apple.Resources.Call.apple_resource_group( + name: Self.copyFilesRoot, + strip_structured_resources_prefixes: [prefix], + structured_resources: .build { + sources.sorted() + }, + visibility: .private)) + } + + func hasCopiedResources(project: Project?) -> Bool { + copiedFileGroups(project: project).contains(where: \.isBundleResource) + } + + /// The resource group, for the library's deps. + func copiedResourceGroups(project: Project?) -> [Starlark.Label] { + hasCopiedResources(project: project) ? [.named(":\(Self.copyFilesRoot)")] : [] + } + + /// The copied files, flattened into the package so that rules_apple places them + /// directly in the destination: it appends the path a file has inside its own + /// package to the destination. + func generateCopiedFiles(_ builder: CodeBuilder, _ kit: Kit) { + for group in copiedFileGroups(project: kit.project) where !group.isBundleResource { + let sources = group.files.map { file in + "\(Self.copyFilesRoot)/\(group.subdirectory)/\(Path(file).lastComponent)" + } + + builder.call( + Rules.Builtin.Call.genrule( + name: group.subdirectory.copyFilesRuleName, + srcs: .build { + sources + }, + outs: sources.map { Path($0).lastComponent }, + cmd: "for src in $(SRCS); do cp $$src $(RULEDIR)/$$(basename $$src); done", + visibility: .private)) + } + } + + /// A tool's binary is named after its rule, so one whose `PRODUCT_NAME` differs + /// is copied to that name first — the app looks it up by name, and a launch + /// daemon's plist points at it. + func generateCopiedProducts(_ builder: CodeBuilder, _ kit: Kit) { + for copied in copiedProducts(project: kit.project) where copied.rename != nil { + guard let rename = copied.rename else { continue } + + builder.call( + Rules.Builtin.Call.genrule( + name: rename.rule, + srcs: .build { + [rename.product] + }, + outs: [rename.name], + cmd: "cp $(location \(rename.product)) $@", + visibility: .private)) + } + } + + // MARK: Private + + private struct CopiedProduct { + let label: String + let subdirectory: String + let rename: (rule: String, product: String, name: String)? + } + + private func copiedProducts(project: Project?) -> [CopiedProduct] { + guard let project else { return [] } + + /// A build phase entry does not say where it comes from, so the copied files + /// answer that: only a product of another target is a rule dependency, a file + /// out of the source tree is just a file. + let products = Set(files.copyFiles.filter { file in + file.sourceTree == "BUILT_PRODUCTS_DIR" + }.compactMap { file in + file.name ?? file.path + }) + + var result: [CopiedProduct] = [] + var seen = Set() + + for phase in buildPhases where phase.type == "CopyFiles" { + guard let subdirectory = phase.contentsSubdirectory else { continue } + + for file in phase.files { + guard + let component = file.name ?? file.path, + products.contains(component), + let sibling = project.product(named: component), + sibling.hasSources, + seen.insert(component).inserted + else { + continue + } + + let product = "//Targets/\(sibling.name):\(sibling.name)" + guard + component != sibling.name, + sibling.productType == "com.apple.product-type.tool" + else { + result.append(.init(label: product, subdirectory: subdirectory, rename: nil)) + continue + } + + let rule = "\(sibling.name)_product" + result.append( + .init( + label: ":\(rule)", + subdirectory: subdirectory, + rename: (rule: rule, product: product, name: component))) + } + } + + return result + } +} + +extension Target { + static let copyFilesRoot = "CopyFiles" + + struct CopiedFileGroup { + let subdirectory: String + let files: [String] + + /// A destination inside the bundle's resource directory, which the target's + /// own library can carry. + var isBundleResource: Bool { + subdirectory == "Resources" || subdirectory.hasPrefix("Resources/") + } + } + + /// Files — not products — a copy phase places in the bundle, grouped by the + /// subdirectory of `Contents` they belong in. + /// + /// The roadmap stages them under `CopyFiles//` so the path a rule + /// sees is the path the bundle wants; a build phase entry itself only names the + /// file, and the same name can be copied to two different places. + func copiedFileGroups(project: Project?) -> [CopiedFileGroup] { + guard let project else { return [] } + + let workspace = Path(project.workspacePath) + + let sources = files.copyFiles.filter { file in + file.sourceTree != "BUILT_PRODUCTS_DIR" + } + let byName = Dictionary( + sources.compactMap { file -> (String, String)? in + guard let path = file.path, let name = file.name ?? file.path else { return nil } + return (name, path) + }, + uniquingKeysWith: { first, _ in first }) + + var groups: [String: [String]] = [:] + + for phase in buildPhases where phase.type == "CopyFiles" { + guard let subdirectory = phase.contentsSubdirectory else { continue } + + for file in phase.files { + guard + let component = file.name ?? file.path, + let path = byName[component], + /// A project routinely references a file nobody ships; a rule + /// naming one fails analysis. + (workspace + Path(path.delete(prefix: "Sources/") ?? path)).exists + else { + continue + } + groups[subdirectory, default: []].append(path) + } + } + + return groups + .map { CopiedFileGroup(subdirectory: $0.key, files: $0.value.sorted()) } + .sorted { $0.subdirectory < $1.subdirectory } + } +} + +extension Project { + /// The target whose product is copied under this file name. + /// + /// A copy phase names the product, which `PRODUCT_NAME` can rename: the Stats + /// `SMC` target builds `smc`, and its `Helper` builds a tool named after the + /// bundle identifier. + fileprivate func product(named component: String) -> Target? { + let base = Path(component).lastComponentWithoutExtension + + return targets.first { target in + let names = [target.name, target.productName, target.prefer(\.metadata.productName)] + .compactMap { $0 } + return names.contains(component) || names.contains(base) + } + } +} + +extension Xcode.BuildPhase { + /// Where a copy phase lands, relative to `Contents`. + /// + /// `nil` for a destination another rule attribute owns — a framework or an + /// extension — and for one no bundle subdirectory can express. + var contentsSubdirectory: String? { + guard let destination else { return nil } + + let path = (destination.path ?? "") + .replacingOccurrences(of: "$(CONTENTS_FOLDER_PATH)", with: "") + .replacingOccurrences(of: "${CONTENTS_FOLDER_PATH}", with: "") + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + + /// `PBXCopyFilesBuildPhase.SubFolder`, as Xcode writes it. + switch destination.subfolderSpec { + case 1, 16: + /// Relative to the wrapper, so the `Contents` prefix is already there. + let trimmed = path.delete(prefix: "Contents/") ?? path + return trimmed.isEmpty ? nil : trimmed + case 6: + return join("MacOS", path) + case 7: + return join("Resources", path) + case 12: + return join("SharedSupport", path) + default: + /// 10 is `frameworks`, 13 is `extensions`, 0 is an absolute path. + return nil + } + } + + private func join(_ base: String, _ path: String) -> String { + path.isEmpty ? base : "\(base)/\(path)" + } +} + +extension String { + /// `Resources/Scripts` names the rule `CopyFiles_Resources_Scripts`. + fileprivate var copyFilesRuleName: String { + "\(Target.copyFilesRoot)_\(replacingOccurrences(of: "/", with: "_"))" + } +} diff --git a/Sources/BazelizeKit/Codegen/Codegen+Framework.swift b/Sources/BazelizeKit/Codegen/Codegen+Framework.swift index 73d2db6..0393c64 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Framework.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Framework.swift @@ -1,35 +1,60 @@ -// -// Codegen+Framework.swift -// -// -// Created by Yume on 2022/4/29. -// - -import BazelRules -import Foundation -import Starlark -import XCode - // TODO: https://github.com/XCodeBazelize/Bazelize/issues/8 framework(static/dynamic) extension Target { - func generateFrameworkCode(_ builder: CodeBuilder, _: Kit) { + func generateFrameworkCode(_ builder: CodeBuilder, _ kit: Kit) { + switch platformSDK { + case .macOS: buildMacFramework(builder, kit) + default: buildIOSFramework(builder, kit) + } + } + + private func buildIOSFramework(_ builder: CodeBuilder, _ kit: Kit) { builder.load(.ios_framework) builder.call( Rules.Apple.IOS.Call.ios_framework( name: name, - bundle_id: prefer(\.bundleID), + bundle_id: bundleIdentifier(project: kit.project), + /// Only the target's own code: a sibling framework is linked through + /// its library, never nested inside this bundle. + deps: .build { + ":\(name)_library" + }, + families: deviceFamilies, + infoplists: .build { + plistFile(kit) + plist_auto + plistDefault(kit) + }, + minimum_os_version: prefer(\.platform.iOS), + resources: .build { + bundleResources(project: kit.project) + }, + strings: .build { + if !allStrings.isEmpty { + ":Strings" + } + }, + visibility: .public)) + } + + private func buildMacFramework(_ builder: CodeBuilder, _ kit: Kit) { + builder.load(.macos_framework) + builder.call( + Rules.Apple.MacOS.Call.macos_framework( + name: name, + bundle_id: bundleIdentifier(project: kit.project), deps: .build { ":\(name)_library" - frameworks }, - families: prefer(\.deviceFamily)?.map(\.code), infoplists: .build { - plist_file + plistFile(kit) plist_auto - // plist_default + plistDefault(kit) + }, + minimum_os_version: prefer(\.platform.macOS), + resources: .build { + bundleResources(project: kit.project) }, - minimum_os_version: prefer(\.iOS), visibility: .public)) } } diff --git a/Sources/BazelizeKit/Codegen/Codegen+Headers.swift b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift new file mode 100644 index 0000000..78c310c --- /dev/null +++ b/Sources/BazelizeKit/Codegen/Codegen+Headers.swift @@ -0,0 +1,233 @@ +import Foundation +import PathKit +import Xcode + +/// Headers Xcode resolves through its implicit header map. +/// +/// Xcode builds a header map covering every header in the target, so +/// `#import "Other.h"` works no matter which directory the header lives in and +/// whether it belongs to a build phase at all. Bazel resolves includes by path, +/// so those headers have to be declared as inputs and their directories exposed +/// as include paths. +extension Target { + // MARK: Internal + + /// Headers that belong in the module: Xcode only publishes the ones flagged + /// `Public` or `Private` in the Headers phase, and modularizing the rest breaks + /// the module build (project headers routinely pull in C++ or private SDK code). + func moduleHeaderFiles(project _: Project) -> [String] { + let exported = exportedHeaders + /// Targets that publish nothing still need their headers reachable, so fall + /// back to treating them all as module headers. + return exported.isEmpty ? Array(Set(headers)).sorted() : exported.sorted() + } + + /// The same headers, addressed through the flattened `Headers//` tree the + /// roadmap materializes. + /// + /// Xcode copies a framework's published headers into one flat directory, which is + /// why `#import ` works no matter where the header lives in the + /// project. Bazel needs that directory to exist for the same imports to resolve. + func flattenedModuleHeaderFiles(project: Project) -> [String] { + moduleHeaderFiles(project: project).map { header in + "\(Self.moduleHeaderRoot)/\(codegenModuleName)/\(Path(header).lastComponent)" + } + } + + static let moduleHeaderRoot = "Headers" + + /// Headers that are compile inputs only. + func internalHeaderFiles(project: Project) -> [String] { + let module = Set(moduleHeaderFiles(project: project)) + let siblings = siblingHeaderPaths(project: project).map { "Sources/\($0)" } + let searched = searchPathHeaderFiles(project: project) + return Array(Set(projectHeaders + siblings + searched).subtracting(module)).sorted() + } + + /// Headers reachable only through `HEADER_SEARCH_PATHS`. + /// + /// Bazel sandboxes compile actions, so an include path is useless unless the + /// headers behind it are declared inputs. + func searchPathHeaderFiles(project: Project) -> [String] { + let workspace = Path(project.workspacePath) + + return headerSearchPaths(project: project).flatMap { directory -> [String] in + let root = workspace + directory + guard root.isDirectory, let children = try? root.recursiveChildren() else { return [] } + + return children + .filter(\.isHeader) + .compactMap { child -> String? in + let absolute = child.absolute().string + let prefix = root.absolute().string + "/" + guard absolute.hasPrefix(prefix) else { return nil } + return "Sources/\(directory)/\(absolute.dropFirst(prefix.count))" + } + } + } + + func headerIncludes(project: Project) -> [String] { + let all = moduleHeaderFiles(project: project) + internalHeaderFiles(project: project) + let directories = all.map { header in + Path(header).parent().string + } + headerSearchPaths(project: project).map { path in + "Sources/\(path)" + } + [Self.moduleHeaderRoot, "\(Self.moduleHeaderRoot)/\(codegenModuleName)"] + + /// "." keeps a public header reachable by its own relative path. + /// https://github.com/bazelbuild/bazel/issues/92 + /// + /// ".." is the `Targets/` root: a package is named after its target, so it + /// makes `#import ` — the generated Swift header Xcode + /// publishes inside the framework — and cross-target framework-style imports + /// resolve, in both the source and the generated file tree. + return Array(Set(directories + [".", ".."])).sorted() + } + + /// `GCC_PREPROCESSOR_DEFINITIONS`, split by what survives a command line. + /// + /// A plain `NAME=1` goes in as a `-D` copt, because that is the only form a + /// clang module build sees: a module is compiled in its own clang instance, + /// which ignores a force-included header but hashes the `-D` flags. UTM's + /// `#if !defined(WITH_USB)` in a header the mixed target modularizes needs + /// exactly that. + /// + /// Anything else — `ID=@"com.x"` — cannot survive: Bazel re-tokenizes the + /// rules' `defines` attribute and the rules_swift worker's param files mangle + /// the quoting of a copt, so those are force-included as a header instead. + static let definesHeaderPath = "Generated/BazelizeDefines.h" + + /// `NAME`, or `NAME=` followed by characters no shell or param file rewrites. + private static let plainDefinePattern = #"^[A-Za-z_][A-Za-z0-9_]*(=[A-Za-z0-9_./+-]*)?$"# + + private var definitions: (plain: [String], quoted: [String]) { + let all = prefer(\.preprocessorDefinitions) ?? [] + return ( + plain: all.filter { $0.range(of: Self.plainDefinePattern, options: .regularExpression) != nil }, + quoted: all.filter { $0.range(of: Self.plainDefinePattern, options: .regularExpression) == nil }) + } + + var definesHeader: String? { + definitions.quoted.isEmpty ? nil : Self.definesHeaderPath + } + + var headerDefinitions: [String] { + definitions.quoted + } + + /// `GCC_PREFIX_HEADER`, relative to the target's `Sources/` tree. + var prefixHeader: String? { + guard let header = prefer(\.prefixHeader), !header.hasPrefix("/") else { return nil } + return "Sources/\(Path(header).normalize().string)" + } + + private var prefixHeaderFlags: [String] { + guard let prefixHeader else { return [] } + return ["-include", "Targets/\(name)/\(prefixHeader)"] + } + + var forceIncludeFlags: [String] { + let defines = definitions.plain.map { definition in + "-D\(definition)" + } + let header = definesHeader.map { path in + ["-include", "Targets/\(name)/\(path)"] + } ?? [] + return defines + header + prefixHeaderFlags + } + + /// The same header, force-included into `swiftc`'s clang importer so a bridging + /// or umbrella header can rely on the definitions. + func forceIncludeCopts() -> [String] { + forceIncludeFlags.flatMap { flag in + ["-Xcc", flag] + } + } + + /// The same include paths, spelled for `swiftc`'s clang importer. + /// + /// Bazel resolves the `includes` attribute relative to the package, raw `-I` + /// flags relative to the execution root. + func swiftIncludeCopts(project: Project) -> [String] { + headerIncludes(project: project) + .filter { $0 != "." } + .flatMap { directory in + ["-Xcc", "-ITargets/\(name)/\(directory)"] + } + } + + /// Workspace-relative `HEADER_SEARCH_PATHS` entries. + /// + /// Xcode resolves them against the project; anything outside the workspace + /// cannot be materialized into the target tree and is dropped. + func headerSearchPaths(project: Project) -> [String] { + let workspace = Path(project.workspacePath).absolute().string + + return (prefer(\.headerSearchPaths) ?? []).compactMap { path -> String? in + /// Xcode quotes segments and allows `$(SETTING:modifier)`; a path that + /// still carries either cannot be resolved to a directory here. + let unquoted = path.replacingOccurrences(of: "\"", with: "") + guard !unquoted.contains("$") else { return nil } + + let normalized = Path(unquoted).normalize().string + guard normalized != "." else { return nil } + + if !normalized.hasPrefix("/") { + return normalized + } + + let prefix = workspace + "/" + guard normalized.hasPrefix(prefix) else { return nil } + return String(normalized.dropFirst(prefix.count)) + } + } + + /// Workspace-relative headers that sit next to the target's compiled sources. + func siblingHeaderPaths(project: Project) -> [String] { + let workspace = Path(project.workspacePath) + + return sourceDirectories.flatMap { directory -> [String] in + let sourceDirectory = directory.isEmpty ? workspace : workspace + directory + guard sourceDirectory.isDirectory, let children = try? sourceDirectory.children() else { + return [] + } + + return children + .filter(\.isHeader) + .map { child in + directory.isEmpty ? child.lastComponent : "\(directory)/\(child.lastComponent)" + } + } + } + + /// Bazel picks the clang dialect from the file extension, Xcode from the + /// declared file type. When every C-family source in the target is + /// Objective-C++ but not named `.mm`, the dialect has to be forced. + var clangDialectCopts: [String] { + let clangSources = srcs_c + srcs_cpp + srcs_objc + srcs_objcpp + guard !clangSources.isEmpty else { return [] } + guard srcs_objcpp.count == clangSources.count else { return [] } + guard srcs_objcpp.contains(where: { !$0.hasSuffix(".mm") }) else { return [] } + + return ["-x", "objective-c++"] + } + + // MARK: Private + + /// Workspace-relative directories holding the target's compiled sources. + private var sourceDirectories: Set { + Set( + srcs.map { source in + let relative = source.delete(prefix: "Sources/") ?? source + let directory = Path(relative).parent().string + return directory == "." ? "" : directory + }) + } +} + +extension Path { + var isHeader: Bool { + guard let ext = `extension`?.lowercased() else { return false } + return ["h", "hh", "hpp", "hxx"].contains(ext) + } +} diff --git a/Sources/BazelizeKit/Codegen/Codegen+Intent.swift b/Sources/BazelizeKit/Codegen/Codegen+Intent.swift new file mode 100644 index 0000000..cf4bd2f --- /dev/null +++ b/Sources/BazelizeKit/Codegen/Codegen+Intent.swift @@ -0,0 +1,46 @@ +import BazelRules +import Foundation +import PathKit +import Starlark + +/// `.intentdefinition` +/// +/// Xcode compiles intent definitions into the target's own module, so the +/// generated sources are fed straight into the target's library instead of +/// becoming a separate module the sources would have to import. +extension Target { + // MARK: Internal + + var intentDefinitions: [String] { + srcs.filter { $0.hasSuffix(".intentdefinition") } + } + + var intentSources: [Starlark.Label] { + intentDefinitions.map { definition in + .named(":\(Self.intentTargetName(for: definition))") + } + } + + func generateIntentLibraries(_ builder: CodeBuilder, _: Kit) { + guard !intentDefinitions.isEmpty else { return } + + builder.load(loadableRule: Rules.Apple.Resources.apple_intent_library) + + for definition in intentDefinitions { + builder.call( + Rules.Apple.Resources.Call.apple_intent_library( + name: Self.intentTargetName(for: definition), + src: .named(definition), + language: "Swift", + tags: ["manual"], + testonly: isTest, + visibility: .private)) + } + } + + // MARK: Private + + private static func intentTargetName(for definition: String) -> String { + "\(Path(definition).lastComponentWithoutExtension)_intent" + } +} diff --git a/Sources/BazelizeKit/Codegen/Codegen+Platform.swift b/Sources/BazelizeKit/Codegen/Codegen+Platform.swift new file mode 100644 index 0000000..85d64fc --- /dev/null +++ b/Sources/BazelizeKit/Codegen/Codegen+Platform.swift @@ -0,0 +1,25 @@ +import Xcode + +extension Target { + /// The platform the target builds for. + /// + /// `SDKROOT` is optional in a project file — Xcode falls back to the platform + /// implied by the deployment target — so the rule choice cannot depend on the + /// setting being present. + var platformSDK: SDK? { + prefer(\.platform.resolvedSDK) + } + + /// The device families a bundle rule is built for. + /// + /// `TARGETED_DEVICE_FAMILY` is optional in a project file: Xcode then builds + /// for every family the platform has, and an iOS bundle rule requires the + /// attribute, so the default has to be stated. + var deviceFamilies: [String]? { + if let declared = prefer(\.platform.deviceFamily), !declared.isEmpty { + return declared.map(\.code) + } + + return platformSDK == .iOS ? [Xcode.DeviceFamily.iphone.code, Xcode.DeviceFamily.ipad.code] : nil + } +} diff --git a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift index 12d7bc6..a3b8308 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Plist.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Plist.swift @@ -9,12 +9,15 @@ import BazelRules import Foundation import PathKit import Starlark -import XCode +import Util extension Target { - func generateLoadPlistFragment(_ builder: CodeBuilder) { - let isGeneratePlist = plistContent != nil - guard isGeneratePlist || isGeneratePlistAuto || isGeneratePlistDefault else { + func generateLoadPlistFragment(_ builder: CodeBuilder, _ kit: Kit) { + guard + plistContent(project: kit.project) != nil || + isGeneratePlistAuto(project: kit.project) || + isGeneratePlistDefault(project: kit.project) + else { return } builder.load(loadableRule: Rules.Plist.plist_fragment) @@ -26,15 +29,14 @@ extension Target { extension Target { // MARK: Internal - var plist_file: Starlark.Label? { - if let _ = plistContent { - return ":plist_file" - } - return nil + /// Mirrors `generatePlistFile`: the label has to disappear when the file is + /// missing or unreadable, otherwise the rule references a target nobody emits. + func plistFile(_ kit: Kit) -> Starlark.Label? { + plistContent(project: kit.project) == nil ? nil : ":plist_file" } - func generatePlistFile(_ builder: CodeBuilder, _: Kit) { - guard let plist = plistContent else { return } + func generatePlistFile(_ builder: CodeBuilder, _ kit: Kit) { + guard let plist = plistContent(project: kit.project) else { return } builder.call( Rules.Plist.Call.plist_fragment( name: "plist_file", @@ -47,81 +49,301 @@ extension Target { visibility: .private)) } + /// Keys the emitted `plist_file` fragment actually defines. + /// + /// Read from the fragment rather than the source `Info.plist`, so a key dropped + /// for being unresolvable still gets its build-setting default. + func infoPlistKeys(project: Project?) -> Set { + guard let content = plistContent(project: project) else { return [] } + guard let regex = try? NSRegularExpression(pattern: #"([^<]+)"#) else { return [] } + + let matches = regex.matches(in: content, range: NSRange(content.startIndex..., in: content)) + return Set( + matches.compactMap { match in + Range(match.range(at: 1), in: content).map { String(content[$0]) } + }) + } + // MARK: Private - private var plistContent: String? { - guard let plistPath = prefer(\.infoPlist) else { - return nil + /// One resolved value out of the target's own `Info.plist`, which outranks the + /// build setting a default would fall back to. + func infoPlistString(_ key: String, project: Project?) -> String? { + guard let nodes = infoPlistNodes(project: project) else { return nil } + + let settings = selectedSettings + var pendingKey: String? + + for node in nodes { + guard let element = node as? XMLElement else { continue } + + if element.name == "key" { + pendingKey = element.stringValue + continue + } + + defer { pendingKey = nil } + guard pendingKey == key, element.name == "string" else { continue } + return element.stringValue?.resolvingBuildSettingReferences(with: settings) } - let path: Path = project.workspacePath + plistPath + return nil + } + + private func infoPlistNodes(project: Project?) -> [XMLNode]? { + guard let project else { return nil } + guard let plistPath = prefer(\.plist.infoPlist) else { return nil } + + let path = Path(project.workspacePath) + plistPath guard let content: String = try? path.read() else { return nil } - guard - let xml = try? XMLDocument(xmlString: content, options: .documentXInclude) - .rootElement()? - .elements(forName: "dict") - .first? - .children else { return nil } + return try? XMLDocument(xmlString: content, options: .documentXInclude) + .rootElement()? + .elements(forName: "dict") + .first? + .children + } + + private func plistContent(project: Project?) -> String? { + guard let nodes = infoPlistNodes(project: project) else { return nil } + + /// Keys `plisttool` cannot resolve for this product are as unusable in the + /// target's own `Info.plist` as they are in a default: a command line tool + /// has no `CFBundleExecutable` to substitute. + var dropped = unsupportedDefaultPlistKeys + dropped.formUnion(appIcons(project: project) == nil ? [] : Self.iconKeys) + /// The version an embedded bundle declares has to give way to its parent's. + if embeddingBundle(project: project) != nil { + dropped.formUnion(Self.versionPatterns.keys) + } + return entries(nodes, dropping: dropped).withNewLine.escapedForPlistFragment + } + + /// `macos_application`/`ios_application` derive these from `app_icons`, and + /// `plisttool` fails the build when a fragment disagrees with what it wrote. + private static let iconKeys: Set = [ + "CFBundleIconFile", + "CFBundleIconFiles", + "CFBundleIconName", + ] + + /// The plist `dict` is a flat ``/value sequence, so a dropped key takes the + /// element that follows it with it. + /// + /// Entries whose value still references an unresolvable build setting are + /// dropped as well: `plisttool` fails the build on a variable it cannot + /// substitute, e.g. Xcode built-ins like `$(SDK_VERSION)`. + private func entries(_ nodes: [XMLNode], dropping keys: Set) -> [String] { + let settings = selectedSettings + var result: [String] = [] + var pendingKey: (name: String, xml: String)? + + for node in nodes { + guard let element = node as? XMLElement else { continue } + element.detach() + + let xml = element + .xmlString(options: [.nodePrettyPrint, .nodePreserveAll]) + .replacingOccurrences(of: "$(PRODUCT_MODULE_NAME)", with: "$(PRODUCT_NAME)") + .resolvingBuildSettingReferences(with: settings) + + if element.name == "key" { + pendingKey = (element.stringValue ?? "", xml) + continue + } + + guard let key = pendingKey else { + result.append(xml) + continue + } + pendingKey = nil + + guard !keys.contains(key.name) else { continue } + + if xml.hasUnresolvedBuildSettingReference() { + Log.codeGenerate.warning(""" + Drop Info.plist key \(key.name, privacy: .public) of \ + \(name, privacy: .public): unresolved build setting reference + """) + continue + } + + if isInvalidVersion(key: key.name, value: element.stringValue ?? "") { + Log.codeGenerate.warning(""" + Drop Info.plist key \(key.name, privacy: .public) of \ + \(name, privacy: .public): value is not a valid version + """) + continue + } + + result.append(key.xml) + result.append(xml) + } + + return result + } + + /// Xcode ships whatever the `Info.plist` says and lets a release script fill + /// the real number in later — MacPass writes a literal `UNDEFINED`. rules_apple + /// validates the format instead, so an unusable value is dropped and the + /// build-setting default takes over. + private func isInvalidVersion(key: String, value: String) -> Bool { + guard let pattern = Self.versionPatterns[key] else { return false } + guard !value.contains("$(") else { return false } + return value.range(of: pattern, options: .regularExpression) == nil + } + + /// What rules_apple accepts for each key, mirroring its `plisttool`. + private static let versionPatterns: [String: String] = [ + "CFBundleVersion": #"^[0-9]+(\.[0-9]+){0,3}([a-z]+[0-9]{1,3})?$"#, + "CFBundleShortVersionString": #"^[0-9]+(\.[0-9]+){0,3}$"#, + ] + + /// A build setting is no better a source than the `Info.plist`: MacPass sets + /// `CURRENT_PROJECT_VERSION` to `${CURRENT_PROJECT_VERSION}`, which is neither a + /// version nor something `plist_fragment` can carry. + private func version(_ value: String?, key: String) -> String? { + guard let value, !value.isEmpty, !value.contains("$") else { return nil } + guard let pattern = Self.versionPatterns[key] else { return value } + return value.range(of: pattern, options: .regularExpression) == nil ? nil : value + } +} + +extension String { + /// Xcode accepts both `$(SETTING)` and `${SETTING}`, each with a modifier — + /// `$(SETTING:default=value)` is the one that carries information. + static let buildSettingPattern = #"\$[({]([A-Za-z0-9_]+)(?::([^)}]*))?[)}]"# + + /// Variables `plisttool` substitutes itself; leaving them intact keeps + /// rules_apple in charge of the bundle identity it also validates. + static let plistToolVariables: Set = [ + "BUNDLE_NAME", + "DEVELOPMENT_LANGUAGE", + "EXECUTABLE_NAME", + "PRODUCT_BUNDLE_IDENTIFIER", + "PRODUCT_NAME", + "TARGET_NAME", + ] + + /// Expands the remaining `$(SETTING)` references from the target's build + /// settings. `plisttool` only knows a handful of variables, so anything else + /// copied out of an Xcode `Info.plist` would either reach the bundle verbatim + /// or collide with a resolved value in another fragment. + func resolvingBuildSettingReferences( + with settings: BuildSettings, + reserved: Set = Self.plistToolVariables) + -> String + { + guard let regex = try? NSRegularExpression(pattern: Self.buildSettingPattern) else { return self } - return xml.compactMap { node -> String in - node.detach() - return node.xmlString(options: [.nodePrettyPrint, .nodePreserveAll]) + let matches = regex.matches(in: self, range: NSRange(startIndex..., in: self)) + var result = self + + for match in matches.reversed() { + guard + let wholeRange = Range(match.range(at: 0), in: self), + let keyRange = Range(match.range(at: 1), in: self) + else { + continue + } + + let key = String(self[keyRange]) + guard !reserved.contains(key) else { continue } + + let modifier = Range(match.range(at: 2), in: self).map { String(self[$0]) } + guard let value = settings[key] ?? modifier?.delete(prefix: "default=") else { continue } + + result.replaceSubrange(wholeRange, with: value) + } + + return result + } + + /// `plist_fragment` treats `{...}` as a `--define` placeholder, so a brace that + /// reaches the template fails analysis. Unresolved `${SETTING}` references are + /// rewritten to the equivalent `$(SETTING)`, which `plisttool` also substitutes. + fileprivate var escapedForPlistFragment: String { + guard let regex = try? NSRegularExpression(pattern: #"\$\{([A-Za-z0-9_]+)\}"#) else { return self } + + return regex.stringByReplacingMatches( + in: self, + range: NSRange(startIndex..., in: self), + withTemplate: "\\$($1)") + } + + /// `$(SETTING)` references left after resolution, excluding the ones + /// `plisttool` substitutes itself. + func hasUnresolvedBuildSettingReference(reserved: Set = Self.plistToolVariables) -> Bool { + guard let regex = try? NSRegularExpression(pattern: Self.buildSettingPattern) else { return false } + + return regex.matches(in: self, range: NSRange(startIndex..., in: self)).contains { match in + guard let keyRange = Range(match.range(at: 1), in: self) else { return false } + return !reserved.contains(String(self[keyRange])) } - .withNewLine - .replacingOccurrences(of: "$(PRODUCT_MODULE_NAME)", with: "$(PRODUCT_NAME)") } } /// plist_auto /// -/// plist properties written in XCode config with prefix `INFOPLIST_KEY_` +/// plist properties written in Xcode config with prefix `INFOPLIST_KEY_` extension Target { // MARK: Internal + /// `INFOPLIST_KEY_*` settings only reach the bundle when Xcode generates the + /// `Info.plist`; with a checked-in file they are ignored, and emitting them + /// anyway makes `plisttool` fail on keys the file already defines. var plist_auto: Starlark.Label? { - isGeneratePlistAuto ? ":plist_auto" : nil + hasGeneratedPlistEntries ? ":plist_auto" : nil } - func generatePlistAuto(_ builder: CodeBuilder) { - if isGeneratePlistAuto { - let plist = prefer(\.plist) ?? [] - builder.call( - Rules.Plist.Call.plist_fragment( - name: "plist_auto", - ext: "plist", - template: Starlark.custom(""" - ''' - \(plist.withNewLine) - ''' - """), - visibility: .private)) - } + func generatePlistAuto(_ builder: CodeBuilder, _: Kit) { + guard hasGeneratedPlistEntries else { return } + + builder.call( + Rules.Plist.Call.plist_fragment( + name: "plist_auto", + ext: "plist", + template: Starlark.custom(""" + ''' + \(selectedSettings.generatedPlist.entries.withNewLine) + ''' + """), + visibility: .private)) } // MARK: Private - private var isGeneratePlistAuto: Bool { - let isAutoGen = prefer(\.generateInfoPlist) ?? false - let isEmptyPlist = (prefer(\.plist) ?? []).isEmpty - return isAutoGen && !isEmptyPlist + private var hasGeneratedPlistEntries: Bool { + let settings = selectedSettings + return settings.generatedPlist.enabled && !settings.generatedPlist.entries.isEmpty + } + + private func isGeneratePlistAuto(project: Project?) -> Bool { + guard project != nil else { return false } + return hasGeneratedPlistEntries } } /// plist_default /// -/// Needed plist properties written in XCode config +/// Needed plist properties written in Xcode config extension Target { // MARK: Internal - var plist_default: Starlark.Label? { - isGeneratePlistDefault ? ":plist_default" : nil + func plistDefault(_ kit: Kit) -> Starlark.Label? { + defaultPlistFragments( + for: selectedSettings, + project: kit.project, + skipping: infoPlistKeys(project: kit.project)).isEmpty ? nil : ":plist_default" } - func generatePlistDefault(_ builder: CodeBuilder) { - if isGeneratePlistDefault { - let plist = prefer(\.defaultPlist) ?? [] + func generatePlistDefault(_ builder: CodeBuilder, _ kit: Kit) { + let plist = defaultPlistFragments( + for: selectedSettings, + project: kit.project, + skipping: infoPlistKeys(project: kit.project)) + if !plist.isEmpty { builder.call( Rules.Plist.Call.plist_fragment( name: "plist_default", @@ -137,8 +359,71 @@ extension Target { // MARK: Private - private var isGeneratePlistDefault: Bool { - let plist = prefer(\.defaultPlist) ?? [] - return !plist.isEmpty + private func isGeneratePlistDefault(project: Project?) -> Bool { + guard project != nil else { return false } + return !defaultPlistFragments(for: selectedSettings, project: project).isEmpty + } + + /// `plisttool` substitutes only a handful of variables, so a default whose value + /// it cannot resolve has to be dropped: `macos_command_line_application` bundles + /// no executable. + var unsupportedDefaultPlistKeys: Set { + var keys: Set = [] + if productType == "com.apple.product-type.tool" { + keys.insert("CFBundleExecutable") + } + /// `plisttool` substitutes `$(PRODUCT_BUNDLE_IDENTIFIER)` from the rule's + /// `bundle_id`, which a target without one — iina's command line tools — + /// never sets. + if prefer(\.metadata.bundleID) == nil { + keys.insert("CFBundleIdentifier") + } + return keys + } + + + /// The target's own `Info.plist` is the source of truth Xcode uses, so a + /// default derived from build settings must not restate those keys: `plisttool` + /// rejects two fragments that disagree on one key. + private func defaultPlistFragments( + for settings: BuildSettings, + project: Project?, + skipping existing: Set = []) + -> [String] + { + /// rules_apple requires an embedded bundle to carry the version of the bundle + /// that embeds it — Apple's own rule, which Xcode never enforces. + let parent = embeddingBundle(project: project) + let currentVersion = parent.map { bundle in + bundle.infoPlistString("CFBundleVersion", project: project) + ?? bundle.prefer(\.generatedPlist.currentProjectVersion) + } ?? settings.generatedPlist.currentProjectVersion + let shortVersion = parent.map { bundle in + bundle.infoPlistString("CFBundleShortVersionString", project: project) + ?? bundle.prefer(\.generatedPlist.marketingVersion) + } ?? settings.generatedPlist.marketingVersion + + let defaults = [ + ("CFBundleName", "$(PRODUCT_NAME)"), + ("CFBundleIdentifier", "$(PRODUCT_BUNDLE_IDENTIFIER)"), + /// `plisttool` cannot resolve these, and rules_apple rejects a bundle + /// without them, so an unset setting falls back to Xcode's own template + /// values instead of a literal `$(SETTING)`. + ("CFBundleVersion", version(currentVersion, key: "CFBundleVersion") ?? "1"), + ("CFBundleExecutable", "$(EXECUTABLE_NAME)"), + ("CFBundleDevelopmentRegion", "$(DEVELOPMENT_LANGUAGE)"), + ( + "CFBundleShortVersionString", + version(shortVersion, key: "CFBundleShortVersionString") ?? "1.0"), + ] + + return defaults + .filter { key, _ in !existing.contains(key) && !unsupportedDefaultPlistKeys.contains(key) } + .map { key, value in + """ + \(key) + \(value) + """ + } } } diff --git a/Sources/BazelizeKit/Codegen/Codegen+StaticFramework.swift b/Sources/BazelizeKit/Codegen/Codegen+StaticFramework.swift index 4c391d0..202555f 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+StaticFramework.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+StaticFramework.swift @@ -31,7 +31,7 @@ // # Cocoapod Deps // \(podDeps.indent(2)) // -// # XCode SPM Deps +// # Xcode SPM Deps // \(xcodeSPMDeps.indent(2)) // ], // ) @@ -47,7 +47,7 @@ // infoplists = [":Info.plist"], // deps = [":_\(name)"], // frameworks = [ -// # XCode Target Deps +// # Xcode Target Deps // \(xcodeDeps) // ], // ) diff --git a/Sources/BazelizeKit/Codegen/Codegen+StaticLibrary.swift b/Sources/BazelizeKit/Codegen/Codegen+StaticLibrary.swift index 7da58d9..b3173be 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+StaticLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+StaticLibrary.swift @@ -8,7 +8,6 @@ import BazelRules import Foundation import Starlark -import XCode extension Target { func generateStaticLibrary(_ builder: CodeBuilder, _: Kit) { diff --git a/Sources/BazelizeKit/Codegen/Codegen+Target.swift b/Sources/BazelizeKit/Codegen/Codegen+Target.swift index 75a2ddd..857bb6c 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+Target.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+Target.swift @@ -1,58 +1,73 @@ -// -// Target+Codegen.swift -// -// -// Created by Yume on 2022/4/29. -// - -import Foundation -import PathKit import Util -import XCode extension Target { - var isTest: Bool { - switch native.productType { - case .unitTestBundle: fallthrough - case .ocUnitTestBundle: fallthrough - case .uiTestBundle: - return true - default: return false - } + /// A target's library is compiled through the bundle rule that transitions it + /// to the target's platform. On its own it would be compiled for the host, + /// which is not what an iOS target's sources are written against, so no + /// wildcard pattern may pick one up. + var manual: [String] { + ["manual"] + } + /// A target with no sources of its own has no library to link, so no rule can + /// produce its product: UTM wraps an externally built binary in a bundle that + /// way. Nothing references a rule that is not emitted either. + var hasSources: Bool { + !(srcs_c + srcs_cpp + srcs_objc + srcs_objcpp + srcs_swift).isEmpty } func generateCode(_ kit: Kit) -> String { let builder = CodeBuilder() + generateIntentLibraries(builder, kit) + generateAssetSymbols(builder, kit) + generateCopiedResourceGroup(builder, kit) generateLibrary(builder, kit) - generateLoadPlistFragment(builder) + generateLoadPlistFragment(builder, kit) generatePlistFile(builder, kit) - generatePlistAuto(builder) - generatePlistDefault(builder) + generatePlistAuto(builder, kit) + generatePlistDefault(builder, kit) let name = name - let native = native - switch native.productType { - case .application: + guard hasSources else { + Log.codeGenerate.warning(""" + Name: \(name, privacy: .public) + Type: \(productType ?? "") has no sources + """) + return builder.build() + } + + switch productType { + case "com.apple.product-type.application": generateStrings(builder, kit) + generateCopiedProducts(builder, kit) + generateCopiedFiles(builder, kit) generateApplicationCode(builder, kit) - case .commandLineTool: + case "com.apple.product-type.tool": generateCommandLineApplicationCode(builder, kit) - case .framework: + case "com.apple.product-type.framework": + generateStrings(builder, kit) generateFrameworkCode(builder, kit) -// case .staticFramework: break - case .staticLibrary: + case "com.apple.product-type.library.static": generateStaticLibrary(builder, kit) -// case .appExtension: break - case .unitTestBundle: + case "com.apple.product-type.bundle.unit-test": generateUnitTest(builder, kit) - case .uiTestBundle: + case "com.apple.product-type.bundle.ui-testing": generateUITest(builder, kit) + case "com.apple.product-type.xpc-service": + generateStrings(builder, kit) + generateCopiedProducts(builder, kit) + generateCopiedFiles(builder, kit) + generateXPCService(builder, kit) + case "com.apple.product-type.app-extension": + generateStrings(builder, kit) + generateCopiedProducts(builder, kit) + generateCopiedFiles(builder, kit) + generateExtension(builder, kit) default: Log.codeGenerate.warning(""" Name: \(name, privacy: .public) - Type: \(native.productType?.rawValue ?? "") not gen + Type: \(productType ?? "") not gen """) } return builder.build() diff --git a/Sources/BazelizeKit/Codegen/Codegen+TestHost.swift b/Sources/BazelizeKit/Codegen/Codegen+TestHost.swift new file mode 100644 index 0000000..1286ecf --- /dev/null +++ b/Sources/BazelizeKit/Codegen/Codegen+TestHost.swift @@ -0,0 +1,39 @@ +import Foundation +import PathKit +import Starlark +import Xcode + +extension Target { + /// The application a unit-test bundle is loaded into, from `TEST_HOST` or + /// `BUNDLE_LOADER`. + /// + /// Xcode resolves the bundle's undefined symbols against the host executable + /// with `-bundle_loader`, and its project-wide header map lets the test include + /// the host's headers by name. A Bazel test bundle has neither, so the host's + /// library is linked into it: that covers both. + func testHostLibraries(project: Project?) -> [Starlark.Label] { + guard isTest, let host = testHostTarget(project: project) else { return [] } + + let label = Starlark.Label.named("//Targets/\(host.name):\(host.name)_library") + /// A test target usually also declares the host as a target dependency, and + /// Bazel rejects a duplicated label in `deps`. + return frameworksLibrary.contains(label) ? [] : [label] + } + + // MARK: Private + + private func testHostTarget(project: Project?) -> Target? { + guard let project, let name = hostBundleName else { return nil } + return project.targets.first { $0.name == name } + } + + /// `TEST_HOST` points at the executable inside the host bundle, e.g. + /// `$(BUILT_PRODUCTS_DIR)/MacPass.app/Contents/MacOS/MacPass`. + private var hostBundleName: String? { + guard let setting = prefer(\.testHost) ?? prefer(\.bundleLoader) else { return nil } + + let components = Path(setting).components + guard let bundle = components.first(where: { $0.hasSuffix(".app") }) else { return nil } + return String(bundle.dropLast(".app".count)) + } +} diff --git a/Sources/BazelizeKit/Codegen/Codegen+UITest.swift b/Sources/BazelizeKit/Codegen/Codegen+UITest.swift index 8c3d6ed..d2087e8 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+UITest.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+UITest.swift @@ -8,13 +8,12 @@ import BazelRules import Foundation import Starlark -import XCode extension Target { // MARK: Internal func generateUITest(_ builder: CodeBuilder, _ kit: Kit) { - switch prefer(\.sdk) { + switch platformSDK { case .iOS: generateIOSUITest(builder, kit) case .macOS: generateMacUITest(builder, kit) case .tvOS: generateTVUITest(builder, kit) @@ -33,9 +32,9 @@ extension Target { deps: .build { ":\(name)_library" }, - minimum_os_version: prefer(\.iOS), + minimum_os_version: prefer(\.platform.iOS), test_host: prefer(\.testTargetName).map { target in - .init("//\(target):\(target)") + .init("//Targets/\(target):\(target)") }, visibility: .public)) } @@ -48,9 +47,9 @@ extension Target { deps: .build { ":\(name)_library" }, - minimum_os_version: prefer(\.macOS), + minimum_os_version: prefer(\.platform.macOS), test_host: prefer(\.testTargetName).map { target in - .init("//\(target):\(target)") + .init("//Targets/\(target):\(target)") }, visibility: .public)) } @@ -63,9 +62,9 @@ extension Target { deps: .build { ":\(name)_library" }, - minimum_os_version: prefer(\.tvOS), + minimum_os_version: prefer(\.platform.tvOS), test_host: prefer(\.testTargetName).map { target in - .init("//\(target):\(target)") + .init("//Targets/\(target):\(target)") }, visibility: .public)) } @@ -78,9 +77,9 @@ extension Target { deps: .build { ":\(name)_library" }, - minimum_os_version: prefer(\.watchOS), + minimum_os_version: prefer(\.platform.watchOS), test_host: prefer(\.testTargetName).map { target in - .init("//\(target):\(target)") + .init("//Targets/\(target):\(target)") }, visibility: .public)) } diff --git a/Sources/BazelizeKit/Codegen/Codegen+UnitTest.swift b/Sources/BazelizeKit/Codegen/Codegen+UnitTest.swift index da85e99..6f2ef58 100644 --- a/Sources/BazelizeKit/Codegen/Codegen+UnitTest.swift +++ b/Sources/BazelizeKit/Codegen/Codegen+UnitTest.swift @@ -8,13 +8,12 @@ import BazelRules import Foundation import Starlark -import XCode extension Target { // MARK: Internal func generateUnitTest(_ builder: CodeBuilder, _ kit: Kit) { - switch prefer(\.sdk) { + switch platformSDK { case .iOS: generateIOSUnitTest(builder, kit) case .macOS: generateMacUnitTest(builder, kit) case .tvOS: generateTVUnitTest(builder, kit) @@ -33,7 +32,7 @@ extension Target { deps: .build { ":\(name)_library" }, - minimum_os_version: prefer(\.iOS), + minimum_os_version: prefer(\.platform.iOS), visibility: .public)) } @@ -45,7 +44,7 @@ extension Target { deps: .build { ":\(name)_library" }, - minimum_os_version: prefer(\.macOS), + minimum_os_version: prefer(\.platform.macOS), visibility: .public)) } @@ -57,7 +56,7 @@ extension Target { deps: .build { ":\(name)_library" }, - minimum_os_version: prefer(\.tvOS), + minimum_os_version: prefer(\.platform.tvOS), visibility: .public)) } @@ -69,7 +68,7 @@ extension Target { deps: .build { ":\(name)_library" }, - minimum_os_version: prefer(\.watchOS), + minimum_os_version: prefer(\.platform.watchOS), visibility: .public)) } } diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift index 0f94cb3..75114d0 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+Library.swift @@ -7,14 +7,35 @@ import Foundation import Util -import XCode +import Starlark extension Target { + /// The Swift module name, which is also what the generated Objective-C + /// interop header is named after. + /// + /// `PRODUCT_MODULE_NAME` is the name a target's own sources import — UTM's + /// `iOS` target builds a module called `UTM`, and its Objective-C sources + /// include `UTM-Swift.h`. Without the setting Xcode falls back to the product + /// name, and then to the target name. + var codegenModuleName: String { + let declared = prefer(\.metadata.moduleName) + ?? prefer(\.metadata.productName) + ?? name + + /// An unresolved reference is no name at all; a module name is an + /// identifier, so anything else becomes an underscore. + let resolved = declared.contains("$") || declared.isEmpty ? name : declared + return String(resolved.map { character in + character.isLetter || character.isNumber || character == "_" ? character : "_" + }) + } + func generateLibrary(_ builder: CodeBuilder, _ kit: Kit) { let name = name let cFamily = srcs_c + srcs_cpp + srcs_objc + srcs_objcpp generateAssets(builder, kit) + generateResources(builder, kit) switch (cFamily.isEmpty, srcs_swift.isEmpty) { case (true, false): @@ -22,10 +43,86 @@ extension Target { case (false, true): generateObjcLibrary(builder, kit) case (false, false): - /// TODO: mix objc & swift - Log.codeGenerate.warning("TODO: mix objc & swift") + generateMixedLanguageLibrary(builder, kit) case (true, true): Log.codeGenerate.warning("Target(\(name, privacy: .public)) can't happen") } } + + private func generateMixedLanguageLibrary(_ builder: CodeBuilder, _ kit: Kit) { + let project = kit.project + let plugin = kit.plugins.compactMap { + $0[name] + }.flatMap(\.deps) + + let builtins: [String] = kit.builtinPlugins + .compactMap(\.target) + .reduce([]) { origin, next in + let data = next[name] ?? [] + return origin + data + } + + builder.load(.mixed_language_library) + builder.call( + Rules.Swift.Call.mixed_language_library( + name: "\(name)_mixed", + clang_copts: [ + "-fblocks", + "-fobjc-arc", + "-fPIC", + "-fmodule-name=\(codegenModuleName)", + ] + clangDialectCopts + forceIncludeFlags, + clang_srcs: .build { + srcs_c + srcs_cpp + srcs_objc + srcs_objcpp + internalHeaderFiles(project: project) + definesHeader + }, + data: .build { + if !assets.isEmpty { + ":Assets" + } + copiedResourceGroups(project: project) + }, + enable_modules: prefer(\.enableModules), + hdrs: .build { + flattenedModuleHeaderFiles(project: project) + prefixHeader + /// A mixed target gets the bridging header's declarations through + /// its own clang module: `swiftc` rejects `-import-objc-header` + /// while building a module. + bridgingHeader + }, + includes: headerIncludes(project: project), + linkopts: sdkLinkopts, + module_name: codegenModuleName, + sdk_dylibs: dylibsSDK, + sdk_frameworks: sdkFrameworks(project: project), + swift_copts: moduleSwiftCopts(project: project), + swift_defines: defines(project: project), + swift_srcs: .build { + srcs_swift + intentSources + assetSymbolSources + }, + tags: manual, + weak_sdk_frameworks: weakFrameworksSDK, + deps: .build { + linkedFrameworksLibrary(project: project) + testHostLibraries(project: project) + plugin + builtins + }, + visibility: .private)) + + builder.call( + Rules.Builtin.Call.alias( + name: "\(name)_library", + actual: .named("\(name)_mixed"), + tags: manual, + visibility: .public)) + } + } diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift index 0b0db68..5885c5e 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+ObjcLibrary.swift @@ -8,14 +8,13 @@ import BazelRules import Foundation import Starlark -import XCode // TODO: https://github.com/XCodeBazelize/Bazelize/issues/7 extension Target { - func generateObjcLibrary(_ builder: CodeBuilder, _: Kit) { + func generateObjcLibrary(_ builder: CodeBuilder, _ kit: Kit, aliasPublic: Bool = true) { + let project = kit.project builder.load(.objc_library) - /// "enable_modules" => select(\.enableModules).starlark builder.call( Rules.Objc.Call.objc_library( name: "\(name)_objc", @@ -24,34 +23,47 @@ extension Target { srcs_cpp srcs_objc srcs_objcpp + internalHeaderFiles(project: project) + definesHeader }, hdrs: .build { - // FIXME: (@yume190) TODO: pch - headers - hpps + flattenedModuleHeaderFiles(project: project) + prefixHeader }, deps: .build { - frameworksLibrary + linkedFrameworksLibrary(project: project) + testHostLibraries(project: project) + }, + data: .build { + if !assets.isEmpty { + ":Assets" + } + copiedResourceGroups(project: project) }, copts: [ "-fblocks", "-fobjc-arc", "-fPIC", - "-fmodule-name=\(name)", - ], - includes: [ - /// public header "." - /// https://github.com/bazelbuild/bazel/issues/92 - ".", - ], - module_name: name, + "-fmodule-name=\(codegenModuleName)", + ] + forceIncludeFlags, + enable_modules: prefer(\.enableModules), + includes: headerIncludes(project: project), + linkopts: sdkLinkopts, + module_name: codegenModuleName, + sdk_dylibs: dylibsSDK, + sdk_frameworks: sdkFrameworks(project: project), + tags: manual, testonly: isTest, - visibility: .private)) + visibility: .private, + weak_sdk_frameworks: weakFrameworksSDK)) - builder.call( - Rules.Builtin.Call.alias( - name: "\(name)_library", - actual: .named("\(name)_objc"), - visibility: .public)) + if aliasPublic { + builder.call( + Rules.Builtin.Call.alias( + name: "\(name)_library", + actual: .named("\(name)_objc"), + tags: manual, + visibility: .public)) + } } } diff --git a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift index e8144ae..f7c4687 100644 --- a/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift +++ b/Sources/BazelizeKit/Codegen/Language/Codegen+SwiftLibrary.swift @@ -1,19 +1,14 @@ -// -// Codegen+SwiftLibrary.swift -// -// -// Created by Yume on 2022/7/4. -// - -import BazelRules -import Foundation -import Starlark -import XCode +import PathKit extension Target { // MARK: Internal - func generateSwiftLibrary(_ builder: CodeBuilder, _ kit: Kit) { + func generateSwiftLibrary( + _ builder: CodeBuilder, + _ kit: Kit, + extraDeps: [Starlark.Label] = []) + { + let project = kit.project let plugin = kit.plugins.compactMap { $0[name] }.flatMap(\.deps) @@ -29,13 +24,17 @@ extension Target { builder.call( Rules.Swift.Call.swift_library( name: "\(name)_swift", - module_name: name, + copts: swiftCopts(project: project), + module_name: codegenModuleName, srcs: .build { srcs_swift + intentSources + assetSymbolSources }, deps: .build { - frameworksLibrary - applicationHost + extraDeps + linkedFrameworksLibrary(project: project) + testHostLibraries(project: project) plugin builtins }, @@ -43,10 +42,16 @@ extension Target { if !assets.isEmpty { ":Assets" } - xibs - storyboards + copiedResourceGroups(project: project) }, - defines: defines, + defines: defines(project: project), + linkopts: sdkLinkopts, + swiftc_inputs: .build { + bridgingHeader + definesHeader + prefixHeader + }, + tags: manual, testonly: isTest, visibility: .private)) @@ -54,45 +59,92 @@ extension Target { Rules.Builtin.Call.alias( name: "\(name)_library", actual: .named("\(name)_swift"), + tags: manual, visibility: .public)) } - // MARK: Private - private var defines: Starlark.Value { - select(\.swiftDefine).map { text -> [String] in - let flags: [String] = (text ?? "").split(separator: " ").map(String.init) - - var isPreviousDefine = false - var result: [String] = [] - for flag in flags { - if flag == "-D" { - isPreviousDefine = true - } else if isPreviousDefine { - /// -D ABC - result.append(flag) - isPreviousDefine = false - } else if flag.hasPrefix("-D") { - /// -DABC - result.append(flag.delete(prefix: "-D")) - } - } + /// `SWIFT_OBJC_BRIDGING_HEADER`, relative to the target's `Sources/` tree. + /// + /// rules_swift has no bridging-header attribute, so the header is passed + /// straight to the compiler and declared as a `swiftc_inputs` file. + var bridgingHeader: String? { + guard let header = prefer(\.bridgingHeader), !header.isEmpty, !header.hasPrefix("/") else { return nil } + /// Build settings carry paths like `./Target/Bridge.h`, which Bazel rejects + /// as a label. + return "Sources/\(Path(header).normalize().string)" + } - return result - }.starlark + var bridgingHeaderCopts: [String]? { + guard let bridgingHeader else { return nil } + return ["-import-objc-header", "$(location \(bridgingHeader))"] } - /// Unittest's dependency from application - /// - /// BUNDLE_LOADER - /// $(TEST_HOST) - /// TEST_HOST - /// $(BUILT_PRODUCTS_DIR)/Example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Example - /// build/Debug-iphoneos/Example.app//Example - private var applicationHost: String? { - guard let host = prefer(\.testHost) else { return nil } - guard let _ = prefer(\.bundleLoader) else { return nil } - guard let targetName = host.components(separatedBy: "/").last else { return nil } - return "//\(targetName):\(targetName)_library" + /// Swift compiles a file named `main.swift` as top-level code and emits a `main` + /// symbol. Xcode only does that for executables, so anything else — a framework + /// with a `main.swift` is common — has to be parsed as a library. + var parseAsLibraryCopts: [String] { + switch productType { + case "com.apple.product-type.application", + "com.apple.product-type.tool": + return [] + default: + break + } + + guard srcs_swift.contains(where: { $0.hasSuffix("/main.swift") || $0 == "main.swift" }) else { + return [] + } + + return ["-parse-as-library"] + } + + /// `-default-isolation`, which `swiftc` takes for the whole module. + var defaultIsolationCopts: [String] { + guard let isolation = prefer(\.swiftDefaultActorIsolation) else { return [] } + return ["-default-isolation", isolation] + } + + func swiftCopts(project: Project) -> [String]? { + var copts = (bridgingHeaderCopts ?? []) + parseAsLibraryCopts + defaultIsolationCopts + if bridgingHeader != nil { + copts += swiftIncludeCopts(project: project) + forceIncludeCopts() + } + return copts.isEmpty ? nil : copts + } + + /// Same flags minus the bridging header: a mixed-language target exposes those + /// declarations through its own clang module instead. The module's headers can + /// still reach for the target's include paths, so `swiftc` needs them too. + func moduleSwiftCopts(project: Project) -> [String]? { + let copts = parseAsLibraryCopts + defaultIsolationCopts + + swiftIncludeCopts(project: project) + forceIncludeCopts() + return copts.isEmpty ? nil : copts + } + + /// `swift_library` has no `sdk_frameworks`, so system frameworks and dylibs from + /// the target's Frameworks phase are linked through raw linker flags. + var sdkLinkopts: [String]? { + let searchPaths = frameworkSearchPathsSDK.map { "-F\($0)" } + let frameworks = frameworksSDK.flatMap { ["-framework", $0] } + let weakFrameworks = weakFrameworksSDK.flatMap { ["-weak_framework", $0] } + let dylibs = dylibsSDK.map { name in + "-l\(name.delete(prefix: "lib") ?? name)" + } + + // Bazel expands `$` in `linkopts` as a Make variable; a weak-symbol flag like + // `-Wl,-U,_OBJC_CLASS_$_X` has to escape it. + let extra = (prefer(\.otherLinkerFlags) ?? []).map { flag in + flag.replacingOccurrences(of: "$", with: "$$") + } + let flags = searchPaths + frameworks + weakFrameworks + dylibs + extra + return flags.isEmpty ? nil : flags } + + // MARK: Private + + func defines(project: Project) -> Starlark.Value { + select(\.swiftDefines, project: project).starlark + } + } diff --git a/Sources/BazelizeKit/Codegen/Resource/Codegen+Asset.swift b/Sources/BazelizeKit/Codegen/Resource/Codegen+Asset.swift index 3fd8026..411d13c 100644 --- a/Sources/BazelizeKit/Codegen/Resource/Codegen+Asset.swift +++ b/Sources/BazelizeKit/Codegen/Resource/Codegen+Asset.swift @@ -1,15 +1,3 @@ -// -// Asset.swift -// -// -// Created by Yume on 2023/1/9. -// - -import BazelRules -import Foundation -import Starlark -import XCode - extension Target { /// https://thanhvu.dev/en/2021/07/16/migrating-ios-project-to-bazel-part-2-2/ /// filegroup( @@ -23,28 +11,31 @@ extension Target { /// "Base.lproj/Main.storyboard", /// "Base.lproj/LaunchScreen.storyboard", /// ], - func generateAssets(_ builder: CodeBuilder, _: Kit) { + func generateAssets(_ builder: CodeBuilder, _ kit: Kit) { /// //Example:Assets.xcassets /// to /// Assets.xcassets/** - let files = assets - .map { label in - "\(label.delete(prefix: "//\(name):"))/**" - } - .map { (label: String) in -// if label.hasPrefix("//:") { -// return label.replacingOccurrences(of: "//:", with: "") -// } - // TODO: glob can't use `../` - label - } + let files = assets.map { label in + "\(label)/**" + } guard !files.isEmpty else { return } builder.call( Rules.Builtin.Call.filegroup( name: "Assets", - srcs: Starlark.glob(files), + srcs: Starlark.glob(files, exclude: appIconExcludes(kit)), visibility: .private)) } + + /// App icons reach the bundle through the rule's `app_icons` attribute. Leaving + /// them in the resources too makes rules_apple reject the catalog: it accepts + /// exactly one `*.appiconset`, while Xcode projects routinely ship several and + /// pick one with `ASSETCATALOG_COMPILER_APPICON_NAME`. + private func appIconExcludes(_ kit: Kit) -> [String] { + guard appIcons(project: kit.project) != nil else { return [] } + return assets.map { label in + "\(label)/*.appiconset/**" + } + } } diff --git a/Sources/BazelizeKit/Codegen/Resource/Codegen+AssetSymbols.swift b/Sources/BazelizeKit/Codegen/Resource/Codegen+AssetSymbols.swift new file mode 100644 index 0000000..9121116 --- /dev/null +++ b/Sources/BazelizeKit/Codegen/Resource/Codegen+AssetSymbols.swift @@ -0,0 +1,89 @@ +import BazelRules +import Foundation +import PathKit +import Starlark +import Xcode + +/// `ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS` +/// +/// Xcode 15+ runs `actool` to turn every asset into a Swift member +/// (`ImageResource.icon`, `ColorResource.accent`, `Color.accent`, …) and compiles +/// the result into the target. Without it the sources referencing those members do +/// not build, so the same `actool` invocation is wired up as a `genrule`. +extension Target { + // MARK: Internal + + static let assetSymbolsTarget = "AssetSymbols" + static let assetSymbolsFile = "GeneratedAssetSymbols.swift" + + var assetSymbolSources: [Starlark.Label] { + generatesAssetSymbols ? [.named(":\(Self.assetSymbolsTarget)")] : [] + } + + func generateAssetSymbols(_ builder: CodeBuilder, _: Kit) { + guard generatesAssetSymbols else { return } + + builder.call( + Rules.Builtin.Call.genrule( + name: Self.assetSymbolsTarget, + srcs: Starlark.glob(assets.map { "\($0)/**" }), + outs: [Self.assetSymbolsFile], + cmd: assetSymbolsCommand, + visibility: .private)) + } + + // MARK: Private + + private var generatesAssetSymbols: Bool { + guard prefer(\.assetCatalog.generatesSwiftSymbols) == true else { return false } + return !assets.isEmpty + } + + /// `actool` refuses to emit symbols without a bundle identifier, and it needs to + /// know the platform it is compiling for. Catalog paths are derived from + /// `$(SRCS)` so the command stays correct when a target carries several catalogs. + /// + /// Starlark rejects unknown escape sequences inside the string, so the command + /// avoids backslashes entirely. + private var assetSymbolsCommand: String { + /// `actool` only needs an identifier to key the generated symbols with; one + /// that still references a build setting Xcode would have expanded is no use + /// to it, and `$(…)` in a genrule command is a Make variable to Bazel. + let resolved = (prefer(\.metadata.bundleID) ?? "") + .resolvingBuildSettingReferences(with: selectedSettings, reserved: []) + let fallback = "com.bazelize.\(codegenModuleName)" + let bundleID = resolved.isEmpty || resolved.contains("$") ? fallback : resolved + let arguments = [ + "--platform \(assetSymbolsPlatform)", + "--minimum-deployment-target \(assetSymbolsMinimumOS)", + "--bundle-identifier \(bundleID)", + "--output-format human-readable-text", + "--generate-swift-asset-symbol-extensions YES", + ].joined(separator: " ") + + return """ + set -e + catalogs=$$(for src in $(SRCS); do echo "$${src%%.xcassets/*}.xcassets"; done | sort -u) + compile=$$(mktemp -d) + xcrun actool $$catalogs --compile "$$compile" \(arguments) --generate-swift-asset-symbols $@ > /dev/null + """ + } + + private var assetSymbolsPlatform: String { + switch platformSDK { + case .macOS: return "macosx" + case .tvOS: return "appletvos" + case .watchOS: return "watchos" + default: return "iphoneos" + } + } + + private var assetSymbolsMinimumOS: String { + switch platformSDK { + case .macOS: return prefer(\.platform.macOS) ?? "11.0" + case .tvOS: return prefer(\.platform.tvOS) ?? "15.0" + case .watchOS: return prefer(\.platform.watchOS) ?? "8.0" + default: return prefer(\.platform.iOS) ?? "15.0" + } + } +} diff --git a/Sources/BazelizeKit/Codegen/Resource/Codegen+Entitlements.swift b/Sources/BazelizeKit/Codegen/Resource/Codegen+Entitlements.swift new file mode 100644 index 0000000..fbae587 --- /dev/null +++ b/Sources/BazelizeKit/Codegen/Resource/Codegen+Entitlements.swift @@ -0,0 +1,118 @@ +import Foundation +import PathKit +import Starlark +import Util +import Xcode + +extension Target { + /// The entitlements Xcode signs with, rewritten into the generated tree. + /// + /// The source file is written for Xcode, which expands build settings and the + /// team prefix while signing; rules_apple substitutes neither, and its + /// `plisttool` fails the build on a variable it does not know. + /// Variables rules_apple's `plisttool` substitutes into entitlements itself. + fileprivate static let entitlementVariables: Set = ["CFBundleIdentifier"] + + var entitlementsPath: String? { + guard let entitlements = metadata.entitlements, !entitlements.isEmpty else { return nil } + return "Generated/\(Path(entitlements).lastComponent)" + } + + func entitlementsLabel(project: Project?) -> Starlark.Label? { + entitlementsContent(project: project) == nil ? nil : .named(entitlementsPath ?? "") + } + + /// `nil` when the target declares no entitlements, or when the file is missing: + /// the rule attribute has to disappear with it. + func entitlementsContent(project: Project?) -> String? { + guard let project, let entitlements = metadata.entitlements, !entitlements.isEmpty else { return nil } + + let path = Path(project.workspacePath) + entitlements + guard + let content: String = try? path.read(), + let document = try? XMLDocument(xmlString: content, options: .documentXInclude), + let root = document.rootElement(), + let dict = root.elements(forName: "dict").first + else { + return nil + } + + let entries = resolvedEntitlementEntries(dict.children ?? []) + dict.setChildren(nil) + for entry in entries { + dict.addChild(entry) + } + + return document.xmlString(options: [.nodePrettyPrint, .nodePreserveAll]) + "\n" + } + + // MARK: Private + + /// The `dict` is a flat ``/value sequence, so a dropped key takes the + /// element that follows it with it. + private func resolvedEntitlementEntries(_ nodes: [XMLNode]) -> [XMLElement] { + let settings = selectedSettings + var result: [XMLElement] = [] + var pendingKey: XMLElement? + + for node in nodes { + guard let element = node as? XMLElement else { continue } + element.detach() + element.resolveEntitlementVariables(with: settings, teamPrefix: teamPrefix) + + if element.name == "key" { + pendingKey = element + continue + } + + guard let key = pendingKey else { + result.append(element) + continue + } + pendingKey = nil + + let xml = element.xmlString(options: [.nodePreserveAll]) + if xml.hasUnresolvedBuildSettingReference(reserved: Self.entitlementVariables) { + Log.codeGenerate.warning(""" + Drop entitlement \(key.stringValue ?? "", privacy: .public) of \ + \(name, privacy: .public): unresolved build setting reference + """) + continue + } + + result.append(key) + result.append(element) + } + + return result + } + + /// What Xcode expands `$(AppIdentifierPrefix)` to: the team that signs the + /// bundle, followed by a dot. rules_apple reads it off a provisioning profile, + /// which a generated workspace has none of. + private var teamPrefix: String? { + guard let team = prefer(\.metadata.developmentTeam), !team.isEmpty else { return nil } + return "\(team)." + } +} + +extension XMLElement { + fileprivate func resolveEntitlementVariables(with settings: BuildSettings, teamPrefix: String?) { + let elements = (children ?? []).compactMap { $0 as? XMLElement } + + // Setting `stringValue` replaces the children, so only a leaf is rewritten: + // an `` or a nested `` recurses instead. + if elements.isEmpty { + if let value = stringValue { + stringValue = value + .replacingOccurrences(of: "$(AppIdentifierPrefix)", with: teamPrefix ?? "$(AppIdentifierPrefix)") + .resolvingBuildSettingReferences(with: settings, reserved: Target.entitlementVariables) + } + return + } + + for element in elements { + element.resolveEntitlementVariables(with: settings, teamPrefix: teamPrefix) + } + } +} diff --git a/Sources/BazelizeKit/Codegen/Resource/Codegen+Resources.swift b/Sources/BazelizeKit/Codegen/Resource/Codegen+Resources.swift new file mode 100644 index 0000000..eb32f36 --- /dev/null +++ b/Sources/BazelizeKit/Codegen/Resource/Codegen+Resources.swift @@ -0,0 +1,80 @@ +import BazelRules +import Foundation +import PathKit +import Starlark +import Xcode + +extension Target { + static let resourceGroupName = "Resources" + + /// Everything Xcode's Resources build phase copies into the bundle, minus what a + /// dedicated rule attribute already owns: an asset catalog, a `.strings` table + /// and an app icon set. + /// + /// Without this a generated app links and bundles, but ships no nib and no + /// localization, so it dies the moment it is launched. It is the only place a + /// nib or a storyboard is declared: passing one through the library's `data` as + /// well makes two rules compile it to the same path. + func generateResources(_ builder: CodeBuilder, _ kit: Kit) { + let patterns = resourcePatterns(project: kit.project) + guard !patterns.isEmpty else { return } + + builder.call( + Rules.Builtin.Call.filegroup( + name: Self.resourceGroupName, + srcs: Starlark.glob(patterns), + visibility: .private)) + } + + /// The bundle rule's `resources`: the group above plus the asset catalog. + func bundleResources(project: Project?) -> [Starlark.Label] { + var labels: [Starlark.Label] = [] + if let project, !resourcePatterns(project: project).isEmpty { + labels.append(.named(":\(Self.resourceGroupName)")) + } + if !assets.isEmpty { + labels.append(.named(":Assets")) + } + return labels + } + + // MARK: Private + + /// A resource is either a file or a folder reference — Xcode copies a folder + /// whole — so a directory becomes a recursive glob. + private func resourcePatterns(project: Project) -> [String] { + let workspace = Path(project.workspacePath) + + let patterns = resources.compactMap { resource -> String? in + guard !Self.ownedResourceExtensions.contains(Path(resource).extension ?? "") else { return nil } + + /// The model already addresses a file through the target's `Sources/` + /// tree; the project is where it is read from. + let source = workspace + Path(resource.droppingSourcesPrefix) + guard source.exists else { return nil } + return source.isDirectory ? "\(resource)/**" : resource + } + + return Array(Set(patterns)).sorted() + } + + /// Resources another attribute of the same rule already carries: passing them + /// twice makes rules_apple fail on a duplicated bundle path. + /// + /// An Icon Composer `.icon` bundle is dropped for a different reason: `actool` + /// refuses one whose `icon.json` is a symlink, and every file a Bazel action + /// sees is a symlink. Leaving it in the resources also makes rules_apple reject + /// the `.appiconset` the same project still ships. + private static let ownedResourceExtensions: Set = [ + "icon", + "intentdefinition", + "strings", + "xcassets" + ] +} + +extension String { + fileprivate var droppingSourcesPrefix: String { + hasPrefix("Sources/") ? String(dropFirst("Sources/".count)) : self + } +} diff --git a/Sources/BazelizeKit/Codegen/Resource/Codegen+Strings.swift b/Sources/BazelizeKit/Codegen/Resource/Codegen+Strings.swift index c0bf354..f02a41b 100644 --- a/Sources/BazelizeKit/Codegen/Resource/Codegen+Strings.swift +++ b/Sources/BazelizeKit/Codegen/Resource/Codegen+Strings.swift @@ -8,7 +8,6 @@ import BazelRules import Foundation import Starlark -import XCode extension Target { func generateStrings(_ builder: CodeBuilder, _: Kit) { diff --git a/Sources/BazelizeKit/Kit.swift b/Sources/BazelizeKit/Kit.swift index ccbf957..6274c60 100644 --- a/Sources/BazelizeKit/Kit.swift +++ b/Sources/BazelizeKit/Kit.swift @@ -5,11 +5,8 @@ // Created by Yume on 2022/4/29. // -import Foundation -import PathKit import PluginLoader import Util -import XCode import XcodeProj import Yams @@ -17,35 +14,40 @@ import Yams public final class Kit { let project: Project - - lazy var module = Bazel.Module(project.workspacePath) - lazy var workspace = Bazel.Workspace(project.workspacePath) - lazy var build = Bazel.RootBuild(project.workspacePath) - lazy var config = Bazel.BazelRC(project.workspacePath) + let outputRoot: Path + + private lazy var roadmap = Bazel.Roadmap(output: outputRoot, project: project) + lazy var version = Bazel.Version(outputRoot) + lazy var module = Bazel.Module(outputRoot) + lazy var build = Bazel.RootBuild(outputRoot) + lazy var config = Bazel.BazelRC(outputRoot) + lazy var rootRC = Bazel.RootRC(outputRoot) + lazy var prebuilt = Bazel.PrebuiltBuild(outputRoot) lazy var targetsBuild = project.targets.map { target in - Bazel.TargetBuild(project.workspacePath, target) + Bazel.TargetBuild(outputRoot, target) } /// plugins... var plugins: [Plugin] private lazy var pluginSPM = PluginSwiftPM(self) + lazy var builtinPlugins: [PluginBuiltin] = [ PluginHttpArchive(self), PluginGitRepository(self), pluginSPM, PluginApple(self), PluginSwift(self), - PluginXCodeProj(self), + PluginXcodeProj(self), PluginPlistFragment(self), PluginLinker(self), - PluginImported(self), ] // MARK: Lifecycle - public init(_ projPath: Path, _ preferConfig: String?) async throws { - project = try await Project(projPath, preferConfig) + public init(_ projPath: Path, _ preferConfig: String?, outputPath: Path? = nil) async throws { + project = try Project.load(path: projPath, preferConfig: preferConfig) + outputRoot = outputPath ?? Path(project.workspacePath) plugins = [] try await pluginSPM.loadPackageNames(projPath: projPath) @@ -53,12 +55,16 @@ public final class Kit { // MARK: Public + /// Notes the run has for the user, collected while the package rules were + /// generated. + private var packageTips: [String] = [] + public final func run(_: Path) async throws { defer { tips() } // try await loadPlugins(mainfest) - - generate() + try generate() + try await generateSwiftPackages() } public final func dump() throws { @@ -82,91 +88,186 @@ extension Kit { print(tip) } + if !packageTips.isEmpty { + print("# Swift packages") + packageTips.forEach { tip in + print(tip) + } + } + plugins.forEach { plugin in plugin.tip() } } } +// MARK: - Swift packages +extension Kit { + /// Rules for the packages the project depends on, generated from their + /// manifests instead of by `rules_swift_package_manager`. + private final func generateSwiftPackages() async throws { + let locals = project.packages.local.map { local in + project.workspaceRoot + local.relativePath + } + let workspace = try await SwiftPM.loadWorkspace( + output: outputRoot, + root: project.packageRoot, + locals: locals) + let deployment = await deployment() + + let generator = SwiftPM.Generator( + output: outputRoot, + workspace: workspace, + deployment: deployment) + try await generator.generate(locals: locals) + packageTips = generator.notes + + let count = workspace.packages.count + Log.codeGenerate.info("Generate \(count, privacy: .public) Swift packages") + } + + /// The versions a package's targets end up compiled at: the lowest deployment + /// target of the project's own targets, per platform, because that is the one + /// a package has to be buildable against. + /// + /// A platform no target of the project builds for cannot fail, so it is left + /// out. Where the project says nothing, the oldest version the installed SDK + /// can build for stands in — the same answer SwiftPM reads out of the SDK. + private final func deployment() async -> SwiftPM.Deployment { + var floors: [String: String] = [:] + var platforms: Set = [] + + for target in project.targets { + if let platform = target.platformSDK.flatMap(SwiftPM.Deployment.platform(of:)) { + platforms.insert(platform) + } + + let declared: [(String, String?)] = [ + ("macos", target.prefer(\.platform.macOS)), + ("ios", target.prefer(\.platform.iOS)), + ("tvos", target.prefer(\.platform.tvOS)), + ("watchos", target.prefer(\.platform.watchOS)), + ] + + for (platform, version) in declared { + guard let version, !version.isEmpty else { continue } + guard let floor = floors[platform] else { + floors[platform] = version + continue + } + if SwiftPM.Deployment.isNewer(floor, than: version) { + floors[platform] = version + } + } + } + + /// A platform the project builds for without saying which version: the + /// oldest the installed SDK can build is what Xcode would use. + for platform in platforms where floors[platform] == nil { + floors[platform] = await SwiftPM.Deployment.sdkFloor(platform: platform) + } + + return .init(project: floors) + } +} + // MARK: - Generate extension Kit { - private final func generate() { - generateModule() - generateWorkspace() - generateBuild() - generateConfig() - generateTargetBuild() - generatePluginExtraFile() + private final func generate() throws { + try generateRoadmap() + try generateVersion() + try generateModule() + try generateBuild() + try generateConfig() + try generatePrebuiltBuild() + try generateTargetBuild() + try generatePluginExtraFile() + } + + private func generateRoadmap() throws { + try roadmap.prepare() + } + + private func generateVersion() throws { + try version.path.write(version.code) } /// {WORKSPACE}/MODULE.bazel - private func generateModule() { + private func generateModule() throws { for plugin in builtinPlugins { plugin.module(module.builder) } - try? module.write() + try module.write() let path = module.path Log.codeGenerate.info("Create `Workspace` at \(path, privacy: .public)") } - /// {WORKSPACE}/WORKSPACE - private final func generateWorkspace() { -// for plugin in builtinPlugins { -// plugin.workspace(workspace.builder) -// } -// try? workspace.write() -// -// let path = workspace.path -// Log.codeGenerate.info("Create `Workspace` at \(path, privacy: .public)") - } - /// {WORKSPACE}/BUILD - private final func generateBuild() { + private final func generateBuild() throws { build.setup(config: project.config) + // build.exportUncategorizedFiles(self) for plugin in builtinPlugins { plugin.build(build.builder) } - try? build.write() + try build.write() let path = build.path Log.codeGenerate.info("Create `BUILD` at \(path, privacy: .public)") } - /// {WORKSPACE}/config.bazelrc - private final func generateConfig() { - config.setup(config: project.config) - try? config.write() + /// {WORKSPACE}/config.bazelrc and {WORKSPACE}/.bazelrc + private final func generateConfig() throws { + config.setup(config: project.config, targets: project.targets) + try config.write() + try rootRC.ensureImport() let path = config.path Log.codeGenerate.info("Create `config.bazelrc` at \(path, privacy: .public)") } + private final func generatePrebuiltBuild() throws { + prebuilt.setup(self) + try prebuilt.path.parent().mkpath() + try prebuilt.write() + + let path = prebuilt.path + Log.codeGenerate.info("Create `Prebuilt/BUILD` at \(path, privacy: .public)") + } + /// {WORKSPACE}/Target/BUILD - private final func generateTargetBuild() { + private final func generateTargetBuild() throws { for build in targetsBuild { var build = build - try? build.mkpath() + try build.mkpath() build.setup(self) - try? build.write() + try build.write() let path = build.path Log.codeGenerate.info("Create BUILD at \(path, privacy: .public)") } } - private final func generatePluginExtraFile() { - builtinPlugins.compactMap(\.custom).flatMap { $0 }.forEach { custom in - let path = Path(custom.path) - try? path.parent().mkpath() - try? path.write(custom.content) + private final func generatePluginExtraFile() throws { + try builtinPlugins.compactMap(\.custom).flatMap { $0 }.forEach { custom in + let path = resolvedOutputPath(custom.path) + try path.parent().mkpath() + try path.write(custom.content) + + /// A script is written to be run: `sh_binary` refuses one that is + /// not executable. + if path.extension == "sh" { + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: path.string) + } } - plugins.forEach { plugin in - try? plugin.generateFile(project.workspacePath) + try plugins.forEach { plugin in + try plugin.generateFile(outputRoot) } } } @@ -178,9 +279,9 @@ extension Kit { public final func clear() { clearModule() - clearWorkspace() clearBuild() clearConfig() + clearPrebuiltBuild() clearTargetBuild() clearPluginExtraFile() } @@ -192,11 +293,6 @@ extension Kit { try? module.clear() } - /// {WORKSPACE}/WORKSPACE - private final func clearWorkspace() { - try? workspace.clear() - } - /// {WORKSPACE}/BUILD private final func clearBuild() { try? build.clear() @@ -207,6 +303,10 @@ extension Kit { try? config.clear() } + private final func clearPrebuiltBuild() { + try? prebuilt.clear() + } + /// {WORKSPACE}/Target/BUILD private final func clearTargetBuild() { for build in targetsBuild { @@ -216,8 +316,13 @@ extension Kit { private final func clearPluginExtraFile() { builtinPlugins.compactMap(\.custom).flatMap { $0 }.forEach { custom in - let path = Path(custom.path) + let path = resolvedOutputPath(custom.path) try? path.delete() } } + + private func resolvedOutputPath(_ path: String) -> Path { + let custom = Path(path) + return custom.isAbsolute ? custom : outputRoot + custom + } } diff --git a/Sources/BazelizeKit/Module.swift b/Sources/BazelizeKit/Module.swift new file mode 100644 index 0000000..8941268 --- /dev/null +++ b/Sources/BazelizeKit/Module.swift @@ -0,0 +1,5 @@ +@_exported import BazelRules +@_exported import Foundation +@_exported import PathKit +@_exported import Starlark +@_exported import Xcode diff --git a/Sources/BazelizeKit/Plugin/Plugin+Apple.swift b/Sources/BazelizeKit/Plugin/Plugin+Apple.swift index 7c4d0d8..4b477db 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+Apple.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+Apple.swift @@ -11,12 +11,12 @@ import Foundation /// https://github.com/bazelbuild/rules_apple final class PluginApple: PluginBuiltin { - let repo: Repo.Apple = .v4_3_3 + let dep: BazelDep.Apple = .latest override func module(_ builder: CodeBuilder) { builder.bazel_dep( name: "rules_apple", - version: repo.rawValue, + version: dep.rawValue, repo_name: "build_bazel_rules_apple") } } diff --git a/Sources/BazelizeKit/Plugin/Plugin+Imported.swift b/Sources/BazelizeKit/Plugin/Plugin+Imported.swift deleted file mode 100644 index 8f3857c..0000000 --- a/Sources/BazelizeKit/Plugin/Plugin+Imported.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// Plugin+Imported.swift -// -// -// Created by Yume on 2023/2/7. -// - -import BazelRules -import Foundation -import PathKit -import Starlark -import XCode - -// TODO: check static imported (xc)framework -final class PluginImported: PluginBuiltin { - private lazy var _target: [String : [String]]? = kit.project.targets - .map { target in - (target.name, target) - } - .toDictionary() - .mapValues { target in - target.importFrameworks.map { relativePath in - let name = Path(relativePath).lastComponentWithoutExtension - let label = "//:\(name)" - return label - } - } - - override var target: [String : [String]]? { - _target - } - - override func build(_ builder: CodeBuilder) { - let imported = kit.project.frameworks.filter { file in - file.relativePath != nil - } - - let frameworks = imported.filter { file in - file.lastKnownFileType == .framework - } - - let xcframeworks = imported.filter { file in - file.lastKnownFileType == .xcframework - } - framework(builder, frameworks) - xcframework(builder, xcframeworks) - } - - private func xcframework(_ builder: CodeBuilder, _ files: [File]) { - guard !files.isEmpty else { return } - builder.load(.apple_dynamic_xcframework_import) - - for file in files { - guard let relativePath = file.relativePath else { continue } - let name = Path(relativePath).lastComponentWithoutExtension - builder.call( - Rules.Apple.General.Call.apple_dynamic_xcframework_import( - name: name, - xcframework_imports: Starlark.glob([ - "\(relativePath)/**", - ]), - visibility: .public)) - } - } - - private func framework(_ builder: CodeBuilder, _ files: [File]) { - guard !files.isEmpty else { return } - builder.load(.apple_dynamic_framework_import) - - for file in files { - guard let relativePath = file.relativePath else { continue } - let name = Path(relativePath).lastComponentWithoutExtension - builder.call( - Rules.Apple.General.Call.apple_dynamic_framework_import( - name: name, - framework_imports: Starlark.glob([ - "\(relativePath)/**", - ]), - visibility: .public)) - } - } -} diff --git a/Sources/BazelizeKit/Plugin/Plugin+Linker.swift b/Sources/BazelizeKit/Plugin/Plugin+Linker.swift index b911ab6..b30d6b2 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+Linker.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+Linker.swift @@ -13,10 +13,11 @@ import Foundation /// https://github.com/keith/rules_apple_linker class PluginLinker: PluginBuiltin { + let dep: BazelDep.AppleLinker = .latest + override func module(_ builder: CodeBuilder) { - builder.custom( - """ - bazel_dep(name = "rules_apple_linker", version = "0.3.0") - """) + builder.bazel_dep( + name: "rules_apple_linker", + version: dep.rawValue) } } diff --git a/Sources/BazelizeKit/Plugin/Plugin+Swift.swift b/Sources/BazelizeKit/Plugin/Plugin+Swift.swift index ee190de..813f599 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+Swift.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+Swift.swift @@ -11,12 +11,12 @@ import Foundation /// https://github.com/bazelbuild/rules_swift final class PluginSwift: PluginBuiltin { - let repo: Repo.Swift = .v3_4_1 + let dep: BazelDep.Swift = .latest override func module(_ builder: CodeBuilder) { builder.bazel_dep( name: "rules_swift", - version: repo.rawValue, + version: dep.rawValue, repo_name: "build_bazel_rules_swift") } } diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Facade.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Facade.swift new file mode 100644 index 0000000..1f74900 --- /dev/null +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM+Facade.swift @@ -0,0 +1,27 @@ +// +// Plugin+SwiftPM+Facade.swift +// +// +// One label shape for every Swift package product, whatever generates it. +// + +import Foundation +import PathKit +import Util + +extension PluginSwiftPM { + /// Every package product a target links reaches it through `//Packages`, the + /// one directory the package rules are generated into. + static let packagesDirectory = "Packages" + + /// A product a target links, and the package it belongs to. + struct FacadeProduct { + let package: String + let product: String + } + + /// `//Packages/SFSafeSymbols:SFSafeSymbols` + func facadeLabel(package: String, product: String) -> String { + "//\(Self.packagesDirectory)/\(package):\(product)" + } +} diff --git a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift index b48976c..5eda8be 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+SwiftPM.swift @@ -7,109 +7,113 @@ import Foundation import PathKit -import XCode -import XcodeProj // MARK: - PluginSPM -/// http://github.com/cgrindel/rules_swift_package_manager +/// The manifests SwiftPM resolves the project's packages from. +/// +/// The rules behind those packages are generated by `SwiftPM.Generator`; this +/// plugin only writes what SwiftPM itself reads, and tells each target which +/// product labels to depend on. final class PluginSwiftPM: PluginBuiltin { - private let repo: Repo.SPM = .v1_13_0 - let remotes: [XCodeRemoteSPM] - let locals: [XCodeLocalSPM] - private var packages: [String] = [] + let remotes: [RemotePackage] + let locals: [LocalPackage] + private var projectPath: Path? + func loadPackageNames(projPath: Path) async throws { - let packageSwift = package - let path = Path(packageSwift.path) - try path.write(packageSwift.content) - packages = try await SPMParser - .allPackageNames(path: projPath.parent().string) + projectPath = projPath } override init(_ kit: Kit) { - remotes = kit.project.remoteSPM - locals = kit.project.localSPM + remotes = kit.project.packages.remote + locals = kit.project.packages.local super.init(kit) } - override func module(_ builder: CodeBuilder) { - builder.bazel_dep(name: "rules_swift_package_manager", version: repo.rawValue) - builder.custom(""" - swift_deps = use_extension( - "@rules_swift_package_manager//:extensions.bzl", - "swift_deps", - ) - swift_deps.from_package( - declare_swift_deps_info = True, - resolved = "//:Package.resolved", - swift = "//:Package.swift", - ) - """) - - let names = packages.map { - "\(Self.repositoryName(module: $0))".quoted - }.joined(separator: ",") - builder.custom(""" - use_repo( - swift_deps, - \(names) - ) - """) - } - - private func transformRemote(_ product: XCSwiftPackageProductDependency) -> String? { - guard let url = product.package?.repositoryURL else { return nil } - /// https://github.com/apple/swift-nio.git - let path = Path(url) - - /// swift-nio - let repo = path.lastComponentWithoutExtension.lowercased() - - /// NIO - let product = product.productName + /// The package a product belongs to and the product's own name. `nil` when the + /// product cannot be traced back to a package. + func facadeProduct(_ product: PackageProductDependency) -> FacadeProduct? { + remoteProduct(product) ?? localProduct(product) + } - /// @swiftpkg_swift_nio//:NIO - return """ - @\(Self.repositoryName(module: repo))//:\(product) - """.replacingOccurrences(of: "-", with: "_") + /// NIO, from a remote package. + private func remoteProduct(_ product: PackageProductDependency) -> FacadeProduct? { + let name = product.productName + guard let url = product.package ?? remoteURL(forProduct: name) else { return nil } + + return .init(package: Self.packageDirectoryName(url: url), product: name) } - private func transformLocal(_ product: XCSwiftPackageProductDependency) -> String? { + /// Xcode can reference a package product without linking it back to the package. + /// The repository named after the product is the only sound guess, and it covers + /// the common one-product-per-package layout. + private func remoteURL(forProduct product: String) -> String? { + remotes.compactMap(\.repositoryURL).first { url in + Self.repositoryModuleName(url: url).caseInsensitiveCompare(product) == .orderedSame + } + } + + private func localProduct(_ product: PackageProductDependency) -> FacadeProduct? { let product = product.productName - let local = locals.first { spm in - spm.products.keys.contains(product) + let directory: String + if let packagePath = kit.project.localPackagePathByProduct[product] { + directory = Path(packagePath).lastComponent + } else if let declared = kit.project.localPackageDirectoryByProduct[product] { + directory = declared + } else { + return nil } - guard let local = local else { return nil } - let path = Path(local.path).lastComponent.lowercased() - - return """ - @swiftpkg_\(path)//:\(product) - """ + return .init(package: directory, product: product) } override var target: [String : [String]]? { - let targets = kit.project.targets - - return targets.map { target -> (String, [String]) in - let deps = target.native.packageProductDependencies ?? [] - - let remote = deps.compactMap(transformRemote) - let local = deps.compactMap(transformLocal) - let all: [String] = Set(remote + local).sorted() - return (target.name, all) + kit.project.targets.map { target -> (String, [String]) in + let labels = target.dependencies.packageProducts + .compactMap(facadeProduct) + .map { product in + facadeLabel(package: product.package, product: product.product) + } + return (target.name, Set(labels).sorted()) }.toDictionary() } private var package: PluginBuiltin.Custom { - let spms = remotes.map(\.package) + - locals.map(\.package) + let spms = remotes.compactMap { remote -> String? in + guard let url = remote.repositoryURL else { return nil } + if let version = remote.version { + switch version { + case .upToNextMajorVersion(let version): + return #" .package(url: "\#(url)", from: "\#(version)"),"# + case .upToNextMinorVersion(let version): + return #" .package(url: "\#(url)", .upToNextMinor(from: "\#(version)")),"# + case .exact(let version): + /// An exact version names one commit, and upstream deleting or + /// re-tagging it makes the manifest unresolvable. Xcode's own pin + /// records the revision, so use it when it is available. + if let revision = pinnedRevision(url: url) { + return #" .package(url: "\#(url)", revision: "\#(revision)"), // \#(version)"# + } + return #" .package(url: "\#(url)", exact: "\#(version)"),"# + case .branch(let branch): + return #" .package(url: "\#(url)", branch: "\#(branch)"),"# + case .revision(let revision): + return #" .package(url: "\#(url)", revision: "\#(revision)"),"# + case .range(let from, let to): + return #" .package(url: "\#(url)", "\#(from)"..."\#(to)"),"# + } + } + return #" .package(url: "\#(url)", from: "0.0.1"),"# + } + + locals.map { local in + #" .package(path: "\#(localPackagePath(local))"),"# + } let deps = spms.joined(separator: "\n").indent(2) return .init( path: "Package.swift", content: """ - // swift-tools-version: 5.7 + // swift-tools-version: 6.0 import PackageDescription let package = Package( @@ -121,33 +125,144 @@ final class PluginSwiftPM: PluginBuiltin { """) } - override var tip: String? { - if remotes.isEmpty, locals.isEmpty { return nil } - return """ - # rules_swift_package_manager - After bazelize, run `swift package update` and `bazel mod tidy`. - """ + /// Seeds Xcode's own pins so the first `swift package resolve` keeps the versions + /// the project builds against instead of floating every package to its newest + /// release. Never overwrites an existing file: after the first run the resolved + /// graph belongs to SwiftPM and Bazel. + private var packageResolved: PluginBuiltin.Custom? { + guard !remotes.isEmpty else { return nil } + guard let projectPath else { return nil } + guard !(kit.outputRoot + "Package.resolved").exists else { return nil } + + let resolved = projectPath + "project.xcworkspace/xcshareddata/swiftpm/Package.resolved" + guard let content = try? String(contentsOfFile: resolved.string, encoding: .utf8) else { return nil } + + return .init(path: "Package.resolved", content: content) + } + + /// Revisions Xcode already resolved, keyed by package identity. + private lazy var pinnedRevisions: [String: String] = { + guard let projectPath else { return [:] } + + let resolved = projectPath + "project.xcworkspace/xcshareddata/swiftpm/Package.resolved" + guard + let data = try? Data(contentsOf: URL(fileURLWithPath: resolved.string)), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let pins = json["pins"] as? [[String: Any]] + else { + return [:] + } + + return pins.reduce(into: [String: String]()) { result, pin in + guard + let identity = pin["identity"] as? String, + let state = pin["state"] as? [String: Any], + let revision = state["revision"] as? String + else { + return + } + result[identity] = revision + } + }() + + private func pinnedRevision(url: String) -> String? { + pinnedRevisions[Self.repositoryModuleName(url: url).lowercased()] + } + + /// `bazel run //:plugins`: what brings the files a build tool plugin writes + /// up to date, without generating the workspace again. + /// + /// A plugin decides what it writes, so changing the plugin changes those + /// files while nothing else about the project moves — and the rules glob the + /// directory rather than name the files, so they need no regenerating. This + /// is the workspace's own way to run them, the way `bazel mod tidy` is the + /// workspace's way to fix its module file. + /// + /// The plugins and the tools they run are `data`, so running this builds + /// them: the script speaks to programs Bazel made, not to SwiftPM. The + /// script itself is written by the package generator, which is what knows + /// which programs those are. + override func build(_ builder: CodeBuilder) { + guard hasPackages else { return } + + builder.load(loadableRule: Rules.Shell.sh_binary) + builder.call( + Rules.Shell.Call.sh_binary( + name: "plugins", + srcs: ["plugins.sh"], + data: ["//\(Self.packagesDirectory):plugins"])) } - private var packageRepositories: [String] { - let remoteRepos = remotes.map(\.url).map(Self.repositoryName(url:)) - let localRepos = locals.map(\.path).map(Self.repositoryName(path:)) - return Set(remoteRepos + localRepos).sorted() + override var custom: [PluginBuiltin.Custom]? { + guard hasPackages else { return nil } + + return [package, packageResolved].compactMap { $0 } + [ignore] + } + + /// SwiftPM's working directory is not part of the Bazel workspace: a checkout + /// can carry `BUILD` files of its own, and Bazel would try to load them. + private var ignore: PluginBuiltin.Custom { + .init(path: ".bazelignore", content: ".build\n") } - private static func repositoryName(url: String) -> String { - repositoryName(module: Path(url).lastComponentWithoutExtension) + private var hasPackages: Bool { + !remotes.isEmpty || !locals.isEmpty } - private static func repositoryName(path: String) -> String { - repositoryName(module: Path(path).lastComponent) + /// Where a local package is, relative to the generated manifest. + /// + /// Both ends are resolved first: an output directory reached through a symlink + /// would otherwise climb out of the link's real parent, and SwiftPM resolves + /// the path it is given against that real one. + private func localPackagePath(_ local: LocalPackage) -> String { + let source = (kit.project.workspaceRoot + local.relativePath).absolute() + let base = kit.outputRoot.absolute() + return Self.relativePath( + from: Self.resolved(base.string), + to: Self.resolved(source.string)) } - private static func repositoryName(module: String) -> String { - "swiftpkg_\(sanitize(module.lowercased()))" + /// The real path, symlinks and all. + /// + /// Not `resolvingSymlinksInPath()`: that one drops a leading `/private`, which + /// is exactly the prefix a temporary directory resolves to. + private static func resolved(_ path: String) -> String { + guard let resolved = realpath(path, nil) else { return path } + defer { free(resolved) } + return String(cString: resolved) } - private static func sanitize(_ value: String) -> String { - value.replacingOccurrences(of: "-", with: "_") + /// The directory a package's products are exposed under, named the way a human + /// refers to the package. + static func packageDirectoryName(url: String) -> String { + repositoryModuleName(url: url) + } + + private static func repositoryModuleName(url: String) -> String { + let component = Path(url).lastComponent + if component.hasSuffix(".git") { + return String(component.dropLast(4)) + } + return component + } + + private static func relativePath(from base: String, to target: String) -> String { + let baseURL = URL(fileURLWithPath: base, isDirectory: true).standardized + let targetURL = URL(fileURLWithPath: target, isDirectory: true).standardized + + let baseComponents = baseURL.pathComponents + let targetComponents = targetURL.pathComponents + + var commonCount = 0 + while + commonCount < min(baseComponents.count, targetComponents.count), + baseComponents[commonCount] == targetComponents[commonCount] + { + commonCount += 1 + } + + let upward = Array(repeating: "..", count: baseComponents.count - commonCount) + let downward = Array(targetComponents.dropFirst(commonCount)) + return (upward + downward).joined(separator: "/") } } diff --git a/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift b/Sources/BazelizeKit/Plugin/Plugin+XcodeProj.swift similarity index 85% rename from Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift rename to Sources/BazelizeKit/Plugin/Plugin+XcodeProj.swift index 6bfcd4b..5c360a3 100644 --- a/Sources/BazelizeKit/Plugin/Plugin+XCodeProj.swift +++ b/Sources/BazelizeKit/Plugin/Plugin+XcodeProj.swift @@ -1,23 +1,22 @@ // -// PluginXCodeProj.swift +// PluginXcodeProj.swift // // // Created by Yume on 2023/2/3. // import Foundation -import XCode import XcodeProj -// MARK: - PluginXCodeProj +// MARK: - PluginXcodeProj /// https://github.com/MobileNativeFoundation/rules_xcodeproj -final class PluginXCodeProj: PluginBuiltin { - let repo: Repo.XCodeProj = .v3_6_0 +final class PluginXcodeProj: PluginBuiltin { + let dep: BazelDep.XcodeProj = .latest override func module(_ builder: CodeBuilder) { builder.bazel_dep( name: "rules_xcodeproj", - version: repo.rawValue) + version: dep.rawValue) } // TODO: @@ -32,7 +31,7 @@ final class PluginXCodeProj: PluginBuiltin { .sorted() .map { name in """ - "//\(name):\(name)", + "//Targets/\(name):\(name)", """ }.withNewLine.indent(2) diff --git a/Sources/BazelizeKit/Repo/Repo+Apple.swift b/Sources/BazelizeKit/Repo/Repo+Apple.swift deleted file mode 100644 index 19c29f8..0000000 --- a/Sources/BazelizeKit/Repo/Repo+Apple.swift +++ /dev/null @@ -1,29 +0,0 @@ - -extension Repo { - /// https://github.com/bazelbuild/rules_apple - enum Apple: String { - case v4_5_1 = "4.5.1" - case v4_5_0 = "4.5.0" - case v4_4_0 = "4.4.0" - case v4_3_3 = "4.3.3" - case v4_3_2 = "4.3.2" - // MARK: Internal - - var version: String { - if rawValue.first == "v" { - return String(rawValue.dropFirst()) - } - return rawValue - } - - var sha256: String { - switch self { - case .v4_5_1: return "0831b6b305e22e007c561f8e48f618244091c9b34ff7aa571de66ddb0de6fdbe" - case .v4_5_0: return "34953c6c5666f2bd864a4a2a27599eb6630a42fde18ba57292fa0a7fcb3d851c" - case .v4_4_0: return "c6d8d0361cd7e48067a2cb3bb6bb295182f8e44ee66905f3d578d5a96bcac18c" - case .v4_3_3: return "fad623b4d0dbe7883fffc95a3275eaabfd13bd9336fca6788cb40bee96e5f131" - case .v4_3_2: return "f2b4117fe17b0f1f8a3769e6d760d433fcbf97a8b6ff1797077ec106ccfbe2f2" - } - } - } -} \ No newline at end of file diff --git a/Sources/BazelizeKit/Repo/Repo+Hammer.swift b/Sources/BazelizeKit/Repo/Repo+Hammer.swift deleted file mode 100644 index c58e282..0000000 --- a/Sources/BazelizeKit/Repo/Repo+Hammer.swift +++ /dev/null @@ -1,27 +0,0 @@ - -extension Repo { - /// https://github.com/pinterest/xchammer - enum Hammer: String { - case v3_4_3_3 = "v3.4.3.3" - case v3_4_3_2 = "v3.4.3.2" - case v3_4_3_1 = "v3.4.3.1" - case v3_4_2_2 = "v3.4.2.2" - // MARK: Internal - - var version: String { - if rawValue.first == "v" { - return String(rawValue.dropFirst()) - } - return rawValue - } - - var sha256: String { - switch self { - case .v3_4_3_3: return "1fe8c3a283f3cfc3c7a3765e185103949bfa889c0e806897ac7ac247582d9a80" - case .v3_4_3_2: return "cddf5fd1d0b6015a03a0b6eacd675093c9e0175c25357ab35de2e9a928d60fa5" - case .v3_4_3_1: return "725d55d3f62e82c14544d479877862a4c2b1d4d7e903b38feb239c5a65aaa4c9" - case .v3_4_2_2: return "20892993972a0a1b8dae305eb4f822d6374848a5e5631a811b88d73a0faa038a" - } - } - } -} \ No newline at end of file diff --git a/Sources/BazelizeKit/Repo/Repo+Pod.swift b/Sources/BazelizeKit/Repo/Repo+Pod.swift deleted file mode 100644 index 6e55fc1..0000000 --- a/Sources/BazelizeKit/Repo/Repo+Pod.swift +++ /dev/null @@ -1,29 +0,0 @@ - -extension Repo { - /// https://github.com/pinterest/PodToBUILD - enum Pod: String { - case v4_1_0_412495 = "4.1.0-412495" - case v4_0_0_5787125 = "4.0.0-5787125" - case v4_0_0_2096f5c = "4.0.0-2096f5c" - case v4_0_0_7673f06 = "4.0.0-7673f06" - case v4_0_0_f96b657 = "4.0.0-f96b657" - // MARK: Internal - - var version: String { - if rawValue.first == "v" { - return String(rawValue.dropFirst()) - } - return rawValue - } - - var sha256: String { - switch self { - case .v4_1_0_412495: return "c96bbfb6364a76e09d8239914990328e66074096038728f1a1b26c62d9081af6" - case .v4_0_0_5787125: return "d697642a6ca9d4d0441a5a6132e9f2bf70e8e9ee0080c3c780fe57e698e79d82" - case .v4_0_0_2096f5c: return "27e168882f74adc33c901d4c930bddbdc38282185bedb8737290022891737f02" - case .v4_0_0_7673f06: return "92eccc22950dcc86e86f4cbc3fb538b4b927da2cd765627ba099f30aa7dbf73b" - case .v4_0_0_f96b657: return "1faf148ba6f0e494d5ccd730ae26130a5966161be034e775f76317897cf68aad" - } - } - } -} \ No newline at end of file diff --git a/Sources/BazelizeKit/Repo/Repo+Swift.swift b/Sources/BazelizeKit/Repo/Repo+Swift.swift deleted file mode 100644 index d9c191f..0000000 --- a/Sources/BazelizeKit/Repo/Repo+Swift.swift +++ /dev/null @@ -1,29 +0,0 @@ - -extension Repo { - /// https://github.com/bazelbuild/rules_swift - enum Swift: String { - case v3_5_0 = "3.5.0" - case v3_4_2 = "3.4.2" - case v3_4_1 = "3.4.1" - case v3_4_0 = "3.4.0" - case v3_3_0 = "3.3.0" - // MARK: Internal - - var version: String { - if rawValue.first == "v" { - return String(rawValue.dropFirst()) - } - return rawValue - } - - var sha256: String { - switch self { - case .v3_5_0: return "c98f201bc217d2ce28e01afc78b410d05c67b846c04a7095e4e701b37422ecb2" - case .v3_4_2: return "03a5c2a93398f2fc4d6ddfb76cf80cd957483ec286d34f50cc22cda002aab445" - case .v3_4_1: return "6309d226474c6b9293f790d3da43d3b04dc0a71b75b87df3107871a0ea59d5f6" - case .v3_4_0: return "13219bde174594c7af5403c7f3f41c37d1a62041294a0fd14c0834ca472fa8dc" - case .v3_3_0: return "94136edf1ccdc7b9bb68ff85e006fe698ea161a02fbee55ba1feb4ce71522cfb" - } - } - } -} \ No newline at end of file diff --git a/Sources/BazelizeKit/Repo/Repo+SwiftPM.swift b/Sources/BazelizeKit/Repo/Repo+SwiftPM.swift deleted file mode 100644 index f54db48..0000000 --- a/Sources/BazelizeKit/Repo/Repo+SwiftPM.swift +++ /dev/null @@ -1,19 +0,0 @@ -extension Repo { - /// http://github.com/cgrindel/rules_swift_package_manager - enum SPM: String { - case v1_13_0 = "1.13.0" - - // MARK: Internal - - var version: String { - if rawValue.first == "v" { - return String(rawValue.dropFirst()) - } - return rawValue - } - - var sha256: String { - "" - } - } -} diff --git a/Sources/BazelizeKit/Repo/Repo+XCodeProj.swift b/Sources/BazelizeKit/Repo/Repo+XCodeProj.swift deleted file mode 100644 index a1371a6..0000000 --- a/Sources/BazelizeKit/Repo/Repo+XCodeProj.swift +++ /dev/null @@ -1,29 +0,0 @@ - -extension Repo { - /// https://github.com/buildbuddy-io/rules_xcodeproj - enum XCodeProj: String { - case v3_6_0 = "3.6.0" - case v3_5_1 = "3.5.1" - case v3_4_1 = "3.4.1" - case v3_4_0 = "3.4.0" - case v3_3_0 = "3.3.0" - // MARK: Internal - - var version: String { - if rawValue.first == "v" { - return String(rawValue.dropFirst()) - } - return rawValue - } - - var sha256: String { - switch self { - case .v3_6_0: return "207fc87aa2573c9c942d247c1db2416d6feaefd2e5c730ec1d7e640018fb4ca0" - case .v3_5_1: return "dc3872fb50d16bbe7df035ea22eac4f79d0957036e226d6df9de5de389434393" - case .v3_4_1: return "b25cb08c7c6f0c813984ed029f97b40357453d359b5fe494170dbaf46cc9c7db" - case .v3_4_0: return "34473ab1756b357393ac45737718147a8a9b4a5607664bcc1b57fc203ffbf249" - case .v3_3_0: return "78e17fd58175334abf1cdec46caa09b60da9e6cf3a07d51a9ee540f1ba712799" - } - } - } -} \ No newline at end of file diff --git a/Sources/BazelizeKit/Repo/Repo.swift b/Sources/BazelizeKit/Repo/Repo.swift deleted file mode 100644 index 78da7a7..0000000 --- a/Sources/BazelizeKit/Repo/Repo.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// Repo.swift -// -// -// Created by Yume on 2022/7/5. -// - -// MARK: - Repo - -enum Repo { } diff --git a/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift new file mode 100644 index 0000000..3f03ffd --- /dev/null +++ b/Sources/BazelizeKit/Roadmap/BazelizeKit+Roadmap.swift @@ -0,0 +1,302 @@ +import Foundation + +// MARK: - Bazel.Roadmap + +extension Bazel { + struct Roadmap { + let output: Path + let project: Project + + /// Only the directories bazelize owns are wiped. + /// + /// Deleting the whole output root would take the project itself with it when + /// no `--output` is given, and otherwise throw away the resolved SwiftPM and + /// Bazel state that lives next to the generated files. + func prepare() throws { + let targetsRoot = output + "Targets" + let prebuiltRoot = output + "Prebuilt" + + try? targetsRoot.delete() + try? prebuiltRoot.delete() + + try output.mkpath() + try linkPackageResolvedIfPresent(project: project) + try preparePrebuiltFiles(project: project) + try targetsRoot.mkpath() + + for target in project.targets { + try prepare(target: target, project: project, targetsRoot: targetsRoot) + } + } + + private func prepare( + target: Target, + project: Project, + targetsRoot: Path) throws + { + let targetRoot = targetsRoot + target.name + let sourcesRoot = targetRoot + "Sources" + let generatedRoot = targetRoot + "Generated" + + try sourcesRoot.mkpath() + try generatedRoot.mkpath() + + var materializedDirectories = Set() + for relativePath in target.pathsForRoadmapTree(project: project) { + let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let hasMaterializedAncestor = materializedDirectories.contains { existing in + normalizedPath == existing || normalizedPath.hasPrefix(existing + "/") + } + guard !hasMaterializedAncestor else { continue } + + let source = Path(project.workspacePath) + relativePath + guard source.exists else { continue } + guard !source.isSelfReferentialSymlink else { continue } + + let destination = sourcesRoot + relativePath + try materialize(source: source, destination: destination) + if source.isDirectory { + materializedDirectories.insert(normalizedPath) + } + } + + try prepareSiblingHeaders(target: target, project: project, sourcesRoot: sourcesRoot) + try prepareModuleHeaders(target: target, project: project, targetRoot: targetRoot) + try prepareDefinesHeader(target: target, targetRoot: targetRoot) + try prepareEntitlements(target: target, project: project, targetRoot: targetRoot) + try prepareCopiedFiles(target: target, project: project, targetRoot: targetRoot) + } + + /// Files a copy phase places in the bundle, staged under the destination the + /// phase names: the rules address a resource by its path, and Xcode copies + /// the same file name to more than one destination. + private func prepareCopiedFiles(target: Target, project: Project, targetRoot: Path) throws { + let workspace = Path(project.workspacePath) + + for group in target.copiedFileGroups(project: project) { + let destination = targetRoot + Target.copyFilesRoot + group.subdirectory + + for file in group.files { + let relativePath = file.delete(prefix: "Sources/") ?? file + let source = workspace + relativePath + guard source.exists else { continue } + + try destination.mkpath() + try materialize(source: source, destination: destination + Path(relativePath).lastComponent) + } + } + } + + /// The entitlements Xcode signs with, expanded: rules_apple substitutes no + /// build setting, and its `plisttool` fails on a variable it cannot resolve. + private func prepareEntitlements(target: Target, project: Project, targetRoot: Path) throws { + guard + let relativePath = target.entitlementsPath, + let content = target.entitlementsContent(project: project) + else { + return + } + + let destination = targetRoot + relativePath + try destination.parent().mkpath() + try destination.write(content) + } + + /// `GCC_PREPROCESSOR_DEFINITIONS` as a header the compiles force-include. + private func prepareDefinesHeader(target: Target, targetRoot: Path) throws { + guard let relativePath = target.definesHeader else { return } + + let definitions = target.headerDefinitions.map { definition in + guard let separator = definition.firstIndex(of: "=") else { + return "#define \(definition) 1" + } + let key = definition[..` work regardless + /// of where the header lives. The generated tree mirrors that directory. + private func prepareModuleHeaders( + target: Target, + project: Project, + targetRoot: Path) throws + { + let workspace = Path(project.workspacePath) + let moduleRoot = targetRoot + Target.moduleHeaderRoot + target.codegenModuleName + let headers = target.moduleHeaderFiles(project: project) + guard !headers.isEmpty else { return } + + try moduleRoot.mkpath() + + for header in headers { + let relativePath = header.delete(prefix: "Sources/") ?? header + let source = workspace + relativePath + guard source.exists, !source.isSelfReferentialSymlink else { continue } + + let destination = moduleRoot + source.lastComponent + try materialize(source: source, destination: destination) + } + } + + /// Xcode's implicit header map makes every header in the target reachable by + /// file name, even when it belongs to no build phase. Bazel needs the file + /// declared, so headers next to the target's compiled sources come along. + private func prepareSiblingHeaders( + target: Target, + project: Project, + sourcesRoot: Path) throws + { + let workspace = Path(project.workspacePath) + + for relativePath in target.siblingHeaderPaths(project: project) { + let source = workspace + relativePath + guard source.exists, !source.isSelfReferentialSymlink else { continue } + + let destination = sourcesRoot + relativePath + guard !destination.exists, !destination.isSymlink else { continue } + + try materialize(source: source, destination: destination) + } + } + + private func preparePrebuiltFiles(project: Xcode.Project) throws { + let prebuiltRoot = output + "Prebuilt" + try prebuiltRoot.mkpath() + + for file in project.prebuiltFiles { + guard let relativePath = file.path, !relativePath.isEmpty else { continue } + let source = Path(project.workspacePath) + relativePath + guard source.exists else { continue } + guard !source.isSelfReferentialSymlink else { continue } + + let destination = prebuiltRoot + Path(relativePath).lastComponent + try replaceIfNeeded(at: destination) + try destination.symlink(source) + } + } + + private func linkPackageResolvedIfPresent(project: Xcode.Project) throws { + let source = Path(project.workspacePath) + "Package.resolved" + guard source.exists else { return } + + let destination = output + "Package.resolved" + try replaceIfNeeded(at: destination) + try destination.symlink(source) + } + + private func replaceIfNeeded(at path: Path) throws { + guard path.exists || path.isSymlink else { return } + try path.delete() + } + + private func materialize(source: Path, destination: Path) throws { + guard !source.isRoadmapIgnoredFile else { return } + + if source.isDirectory { + if destination.isSymlink { + try destination.delete() + } + if !destination.exists { + try destination.mkpath() + } + for child in try source.children() { + guard !child.isSelfReferentialSymlink else { continue } + try materialize(source: child, destination: destination + child.lastComponent) + } + return + } + + try destination.parent().mkpath() + try replaceIfNeeded(at: destination) + try destination.symlink(source) + } + } +} + +extension Xcode.Target { + fileprivate func pathsForRoadmapTree(project: Project) -> [String] { + let allFiles = files.sources + files.headers + files.resources + files.copyFiles + files.others + let candidates = (allFiles.compactMap(\.roadmapRelativePath) + settingReferencedPaths + headerSearchPaths(project: project)).sorted { + let lhsDepth = $0.split(separator: "/").count + let rhsDepth = $1.split(separator: "/").count + if lhsDepth == rhsDepth { + return $0 < $1 + } + return lhsDepth < rhsDepth + } + + var result: [String] = [] + var seen = Set() + + for path in candidates where seen.insert(path).inserted { + let hasAncestor = result.contains { existing in + path == existing || path.hasPrefix(existing + "/") + } + guard !hasAncestor else { continue } + result.append(path) + } + + return result + } + + /// Files Xcode reaches through build settings instead of a build phase; the + /// bridging header and entitlements are rule inputs, so they need to exist in + /// the target's `Sources/` tree. + fileprivate var settingReferencedPaths: [String] { + [ + prefer(\.bridgingHeader), + prefer(\.prefixHeader), + metadata.entitlements, + ] + .compactMap { $0 } + .filter { !$0.isEmpty && !$0.hasPrefix("/") } + .map { Path($0).normalize().string } + } +} + +extension Xcode.Project { + fileprivate var prebuiltFiles: [Xcode.File] { + let all = targets.flatMap { target in + target.files.frameworks.filter { $0.label?.hasPrefix("//Prebuilt:") == true } + } + + var seen = Set() + return all.filter { file in + guard let path = file.path else { return false } + return seen.insert(path).inserted + } + } +} + +extension Xcode.File { + fileprivate var roadmapRelativePath: String? { + if let path, !path.isEmpty { + return path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + return nil + } +} + +extension Path { + fileprivate var isSelfReferentialSymlink: Bool { + guard isSymlink else { return false } + guard let destination = try? symlinkDestination().absolute() else { return false } + return destination == absolute() + } + + fileprivate var isRoadmapIgnoredFile: Bool { + switch lastComponent { + case "BUILD", "BUILD.bazel": + return true + default: + return false + } + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Binary.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Binary.swift new file mode 100644 index 0000000..a6be5ba --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Binary.swift @@ -0,0 +1,184 @@ +// +// SwiftPM+Binary.swift +// +// +// Rules for a package target that ships a built binary. +// + +import BazelRules +import Foundation +@preconcurrency import PathKit +import Starlark +import Util + +extension SwiftPM.Generator { + /// A binary target is an `.xcframework` SwiftPM already fetched, imported the + /// way a project-owned one is. + /// + /// Whether it links statically or dynamically is not in the manifest, so the + /// binary itself is read: an archive is static, a Mach-O dylib is not. + func buildBinary( + _ target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + root: Path, + builder: CodeBuilder) throws -> Bool + { + guard let xcframework = try artifact(of: target, in: package) else { + Log.codeGenerate.warning(""" + Skip \(package.directory, privacy: .public)/\(target.name, privacy: .public): \ + no artifact for the binary target + """) + return false + } + + /// The link keeps the `.xcframework` name: the import rule reads the + /// bundle name out of the path. + let directory = root + Self.artifactsRoot + target.name + let link = directory + xcframework.lastComponent + try directory.mkpath() + if link.isSymlink || link.exists { + try? link.delete() + } + try link.symlink(xcframework) + + let imports = Starlark.glob([ + "\(Self.artifactsRoot)/\(target.name)/**", + ]) + + if isStatic(xcframework) { + builder.load(.apple_static_xcframework_import) + builder.call( + Rules.Apple.General.Call.apple_static_xcframework_import( + name: ruleName(of: target.name, in: package), + xcframework_imports: imports, + tags: Self.manual, + visibility: .public)) + } else { + builder.load(.apple_dynamic_xcframework_import) + builder.call( + Rules.Apple.General.Call.apple_dynamic_xcframework_import( + name: ruleName(of: target.name, in: package), + xcframework_imports: imports, + tags: Self.manual, + visibility: .public)) + } + + return true + } + + static let artifactsRoot = "Artifacts" + + /// Where the artifact ended up: a remote one was downloaded and unpacked into + /// the workspace's artifact directory, a local one is a path in the package. + private func artifact(of target: SwiftPM.PackageTarget, in package: SwiftPM.Package) throws -> Path? { + var roots: [Path] = [workspace.artifacts + package.identity + target.name] + + if let path = target.path { + roots.append(package.root + path) + } + + for root in roots { + if root.extension == "xcframework", root.exists { return root } + guard root.isDirectory else { continue } + + let children = (try? root.children()) ?? [] + if let xcframework = children.first(where: { $0.extension == "xcframework" }) { + return xcframework + } + } + + return nil + } + + /// Whether the framework in the slice links statically, read from the binary + /// itself: the manifest does not say, and a static archive handed to the + /// dynamic import rule fails in the bitcode stripper. + private func isStatic(_ xcframework: Path) -> Bool { + let slices = ((try? xcframework.children()) ?? []).filter(\.isDirectory) + + for slice in slices { + let entries = (try? slice.children()) ?? [] + + if entries.contains(where: { $0.extension == "a" }) { return true } + + guard let framework = entries.first(where: { $0.extension == "framework" }) else { + continue + } + let binary = framework + framework.lastComponentWithoutExtension + guard let data = try? Data(contentsOf: binary.url) else { continue } + + return Self.isStatic(binary: data, offset: 0) + } + + return false + } + + /// A binary is an archive, a fat file wrapping one per architecture, or a + /// Mach-O image whose type says whether it is a dylib. + private static func isStatic(binary: Data, offset: Int) -> Bool { + guard let magic = binary.marker(at: offset) else { return false } + + switch magic { + case Self.fat32, Self.fat64: + /// A fat header is followed by one entry per architecture, each naming + /// the offset of its image; every slice of one file is of the same + /// kind, so the first answers. The 64-bit entry has a 64-bit offset, + /// whose low word is the one that can address the file. + let field = magic == Self.fat64 ? offset + 20 : offset + 16 + guard let slice = binary.word(at: field, littleEndian: false) else { return false } + return isStatic(binary: binary, offset: Int(slice)) + + case Self.machOBigEndian32, Self.machOBigEndian64: + return binary.fileType(at: offset, littleEndian: false) != Self.dylib + + case Self.machOLittleEndian32, Self.machOLittleEndian64: + return binary.fileType(at: offset, littleEndian: true) != Self.dylib + + default: + /// Neither Mach-O nor fat: a static archive, which is what a static + /// framework's binary is. + return binary.starts(with: Array("!".utf8), at: offset) + } + } + + private static let fat32: UInt32 = 0xCAFE_BABE + private static let fat64: UInt32 = 0xCAFE_BABF + private static let machOBigEndian32: UInt32 = 0xFEED_FACE + private static let machOBigEndian64: UInt32 = 0xFEED_FACF + private static let machOLittleEndian32: UInt32 = 0xCEFA_EDFE + private static let machOLittleEndian64: UInt32 = 0xCFFA_EDFE + /// `MH_DYLIB`. + private static let dylib: UInt32 = 6 +} + +extension Data { + /// The four bytes at an offset in file order, which is what a magic number is. + fileprivate func marker(at offset: Int) -> UInt32? { + word(at: offset, littleEndian: false) + } + + /// `filetype`, the third word of a Mach-O header. + fileprivate func fileType(at offset: Int, littleEndian: Bool) -> UInt32? { + word(at: offset + 12, littleEndian: littleEndian) + } + + /// A 32-bit field, in the image's own byte order. + fileprivate func word(at offset: Int, littleEndian: Bool) -> UInt32? { + guard offset >= 0, count >= offset + 4 else { return nil } + + let start = index(startIndex, offsetBy: offset) + let bytes = Array(self[start ..< index(start, offsetBy: 4)]) + /// Folding from the most significant byte: the first byte in a big-endian + /// field, the last in a little-endian one. + return (littleEndian ? Array(bytes.reversed()) : bytes) + .reduce(UInt32(0)) { result, byte in + result << 8 | UInt32(byte) + } + } + + fileprivate func starts(with prefix: [UInt8], at offset: Int) -> Bool { + guard count >= offset + prefix.count else { return false } + let start = index(startIndex, offsetBy: offset) + return Array(self[start ..< index(start, offsetBy: prefix.count)]) == prefix + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Clang.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Clang.swift new file mode 100644 index 0000000..1f29ad5 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Clang.swift @@ -0,0 +1,303 @@ +// +// SwiftPM+Clang.swift +// +// +// Rules for a package target written in C, Objective-C or C++. +// + +import BazelRules +import Foundation +@preconcurrency import PathKit +import Starlark +import Util + +extension SwiftPM.Generator { + /// A C-family target becomes an `objc_library`: the same rule an Xcode target + /// with C sources uses, so headers, includes and defines behave identically on + /// both sides of the graph. + func buildClang( + _ target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + prefix: String, + root: Path, + generated: PluginGenerated, + resources: ResourceBundle?, + builder: CodeBuilder) + { + guard let directory = sourceDirectory(of: target, in: package) else { return } + + let module = Self.moduleName(target.name) + let extensions = extensions(of: target, in: package) + let headers = publicHeaders(of: target, in: directory) + let compiled = Self.compileExtensions.filter { extensions.contains($0) } + /// Headers are matched against everything on disk: `exclude` can drop a + /// directory that a header search path still points into. + let files = relativeFiles(of: target, in: package, prefix: prefix, excluding: false) + + /// The module map is what names the module: without one the name comes + /// from the label, and neither a Swift `import` nor a C-family `@import` of + /// the target's own name resolves. A map the package wrote itself is kept, + /// because it is the interface the package intends. + let name = ruleName(of: target.name, in: package) + let hint = "\(name)_interop" + let headerPrefix = headers.map { Self.path(prefix, $0) } + let interface = try? mirror( + headersOf: target, + at: headers.map { directory + $0 }, + module: module, + root: root) + + builder.load(loadableRule: Rules.Swift.swift_interop_hint) + builder.call( + Rules.Swift.Call.swift_interop_hint( + name: hint, + module_map: interface.map { .named("\($0)/module.modulemap") }, + module_name: module)) + + builder.load(loadableRule: Rules.Objc.objc_library) + builder.call( + Rules.Objc.Call.objc_library( + name: name, + aspect_hints: .build { [Starlark.Label.named(":\(hint)")] }, + srcs: Starlark.glob( + matching( + sources(of: target, prefix: prefix, extensions: compiled) + /// Private headers are compilation inputs wherever they + /// sit, so they are collected from the whole directory + /// even when the sources are listed one by one. + + (headerPrefix == prefix + ? [] + : Self.headerExtensions.map { "\(prefix)/**/*.\($0)" }), + files) + /// A plugin's output compiles like a source of the target, + /// and the header beside it is an input the same way a + /// private header is: the generated source includes it by + /// name, which is all SwiftPM offers either. + + generated.sources + + generated.headers + + (resources?.accessors ?? []), + exclude: excludedClang(target, prefix: prefix) + + (headerPrefix.map { $0 == prefix ? [] : ["\($0)/**"] } ?? []), + allowEmpty: true), + hdrs: interface + .map { path in + matching( + Self.headerExtensions.map { "\(path)/**/*.\($0)" }, + Self.relativeFiles(under: path, in: root)) + }? + .nonEmpty + .map { Starlark.glob($0) }, + deps: deps(of: target, in: package).nonEmpty.map { labels in + .build { labels } + }, + data: resources.map { bundle in + .build { [Starlark.Label.named(bundle.label)] } + }, + alwayslink: true, + copts: clangCopts( + of: target, + in: package, + module: module, + resources: resources).nonEmpty, + enable_modules: true, + includes: includes( + of: target, + prefix: prefix, + interface: interface).nonEmpty, + linkopts: linkopts(of: target).nonEmpty, + /// The module a dependent's `@import` names: the package target's + /// own name, not the one Bazel derives from the label. + module_name: module, + tags: Self.manual, + /// The map travels with the headers: it is on the include path of + /// everything that depends on the target, and clang has to find the + /// file there. + textual_hdrs: interface.map { path in + .build { [Starlark.Label.named("\(path)/module.modulemap")] } + }, + visibility: .public)) + } + + /// The files of a generated directory, named the way a glob pattern is. + private static func relativeFiles(under directory: String, in root: Path) -> [String] { + let base = root.normalize().string + + return SwiftPM.Generator.walk(root + directory).compactMap { file in + let path = file.normalize().string + guard path.hasPrefix(base) else { return nil } + return String(path.dropFirst(base.count)).trimmingCharacters(in: ["/"]) + } + } + + /// The target's public interface: its headers and the module map, in one + /// directory of our own. + /// + /// clang looks for `module.modulemap` in the directory a header was found in, + /// so the map has to sit next to the headers — and the checkout is not ours to + /// write into. The headers are therefore linked into a generated directory + /// beside the map, the way an Xcode target's flattened header tree works. Every + /// consumer then resolves the module through a header search path alone: a + /// Swift `import`, a C-family `@import`, from this package or any other. + private func mirror( + headersOf target: SwiftPM.PackageTarget, + at headers: Path?, + module: String, + root: Path) throws -> String? + { + guard let headers, headers.isDirectory else { return nil } + + let relative = "Generated/\(target.name)Interface" + let interface = root + relative + if interface.exists || interface.isSymlink { + try? interface.delete() + } + try interface.mkpath() + + let files = SwiftPM.Generator.walk(headers) + let base = headers.normalize().string + var shipped: Path? + + for file in files { + let path = file.normalize().string + guard path.hasPrefix(base) else { continue } + + let name = String(path.dropFirst(base.count)).trimmingCharacters(in: ["/"]) + if name == "module.modulemap" { + shipped = file + continue + } + + let link = interface + name + try link.parent().mkpath() + try link.symlink(file) + } + + /// A map the package ships is its intended interface; without one the + /// module is every header in the directory, which is what SwiftPM + /// generates for a clang target too. + let map = interface + "module.modulemap" + if let shipped { + try map.symlink(shipped) + } else { + try map.write(""" + module \(module) { + umbrella "." + export * + } + + """) + } + + return relative + } + + /// What `exclude` removes from a C-family target. + /// + /// A directory that is also a header search path keeps its headers: they are + /// compilation inputs reached by `-I`, and excluding them leaves the compiler + /// looking for a file the sandbox does not have. Only what would be compiled + /// from there is dropped. + private func excludedClang(_ target: SwiftPM.PackageTarget, prefix: String) -> [String] { + let searched = Set(target.settings.flatMap { setting -> [String] in + guard setting.tool == "c" || setting.tool == "cxx" else { return [] } + guard setting.name == "headerSearchPath" else { return [] } + return setting.values.map { Path($0).normalize().string } + }) + + return target.exclude.flatMap { excluded -> [String] in + let path = Path(excluded).normalize().string + + if searched.contains(path) { + return Self.compileExtensions.map { "\(prefix)/\(path)/**/*.\($0)" } + } + return Path(excluded).extension == nil + ? ["\(prefix)/\(excluded)/**"] + : ["\(prefix)/\(excluded)"] + } + Self.ignoredExtensions.map { "\(prefix)/**/*.\($0)/**" } + } + + /// `publicHeadersPath`, defaulting to the `include` directory SwiftPM looks + /// for. It can also be `.`, meaning the target's own directory. + func publicHeaders(of target: SwiftPM.PackageTarget, in directory: Path) -> String? { + let path = target.publicHeadersPath ?? "include" + return (directory + path).isDirectory ? path : nil + } + + /// A path a glob accepts: no `.` segment survives normalization. + static func path(_ prefix: String, _ path: String) -> String { + Path("\(prefix)/\(path)").normalize().string + } + + /// What a header lookup can reach: the target's interface directory and + /// whatever `headerSearchPath` adds, which is the shape SwiftPM passes. + /// + /// Not the target directory itself: a module map sitting there would be found + /// by clang on its own, and a package that ships one outside its public + /// headers would end up with two maps for the same module. + private func includes( + of target: SwiftPM.PackageTarget, + prefix: String, + interface: String?) -> [String] + { + var paths: [String] = [] + if let interface { + paths.append(interface) + } + + for setting in target.settings + where setting.tool == "c" && setting.name == "headerSearchPath" + { + paths.append(contentsOf: setting.values.map { Self.path(prefix, $0) }) + } + + return NSOrderedSet(array: paths).compactMap { $0 as? String } + } + + /// The module name has to be stated: without it clang names the module after + /// the module map's directory, and a Swift `import` of the target fails. + private func clangCopts( + of target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + module: String, + resources: ResourceBundle?) -> [String] + { + var copts = ["-fmodule-name=\(module)"] + clangDefines(of: target) + + /// SwiftPM force-includes the accessor, so a source reaches its bundle + /// without importing anything. + if let header = resources?.header { + copts.append("-include$(location \(header))") + } + + if let standard = package.manifest.cLanguageStandard { + copts.append("-std=\(standard)") + } + if let standard = package.manifest.cxxLanguageStandard { + copts.append("-std=\(standard)") + } + + for setting in target.settings where setting.tool == "c" || setting.tool == "cxx" { + guard setting.name == "unsafeFlags" else { continue } + copts.append(contentsOf: setting.values) + } + + return copts + } +} + +extension SwiftPM.Generator { + /// `c.define` and `cxx.define`, plus the `SWIFT_PACKAGE` every package target + /// compiles with. + /// + /// Flags, not the `defines` attribute, for the same reason as a Swift target: + /// the attribute would propagate into everything downstream. + func clangDefines(of target: SwiftPM.PackageTarget) -> [String] { + let declared = target.settings.flatMap { setting -> [String] in + guard setting.name == "define" else { return [] } + guard setting.tool == "c" || setting.tool == "cxx" else { return [] } + return setting.values + } + + return (["SWIFT_PACKAGE"] + declared).map { "-D\($0)" } + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Deployment.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Deployment.swift new file mode 100644 index 0000000..c7a8840 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Deployment.swift @@ -0,0 +1,183 @@ +// +// SwiftPM+Deployment.swift +// +// +// The platform version a package's targets are compiled at. +// + +import Foundation +@preconcurrency import PathKit +import Xcode +import Subprocess +import Util + +extension SwiftPM { + /// Which platform version each of a package's targets is compiled at. + /// + /// SwiftPM takes the higher of what the package declares and what the consumer + /// asks for. A package target here is compiled at the project's deployment + /// target, because the version lives in the platform transition of the bundle + /// rule that pulls the target in, and a library rule has no version of its + /// own. A package that requires more than the project does therefore fails to + /// compile, and saying so beats an availability error deep in someone else's + /// source. + struct Deployment: Sendable { + /// The project's deployment target per platform, keyed the way a manifest + /// names the platform. + let project: [String: String] + + /// What the package requires: its own declaration, or the oldest version + /// SwiftPM builds that platform for. + func required(_ package: Package, platform: String) -> String? { + if let declared = package.manifest.platforms.first(where: { $0.platformName == platform }) { + return declared.version + } + + return Self.oldest[platform] + } + + /// The platforms where the package asks for more than the project provides. + /// + /// Only a version the manifest states counts. SwiftPM raises both sides to + /// its own floor before comparing them — a package that declares nothing is + /// never the reason a graph is rejected — so the default is not something to + /// warn about. + func unmet(_ package: Package) -> [(platform: String, required: String, project: String)] { + project.keys.sorted().compactMap { platform in + guard + let floor = project[platform], + let declared = package.manifest.platforms + .first(where: { $0.platformName == platform })? + .version, + Self.isNewer(declared, than: floor) + else { + return nil + } + + return (platform, declared, floor) + } + } + + /// SwiftPM's own floors, the versions it builds a platform for when a + /// package declares nothing. + /// + /// They follow the installed toolchain rather than a table of our own: + /// SwiftPM reads them out of the SDK when it can, and the table is what it + /// falls back to. + static let oldest: [String: String] = [ + "macos": "12.0", + "maccatalyst": "15.0", + "ios": "15.0", + "tvos": "15.0", + "watchos": "9.0", + "visionos": "1.0", + "driverkit": "21.0", + ] + + static func isNewer(_ version: String, than other: String) -> Bool { + let left = components(version) + let right = components(other) + + for index in 0 ..< max(left.count, right.count) { + let lhs = index < left.count ? left[index] : 0 + let rhs = index < right.count ? right[index] : 0 + if lhs != rhs { return lhs > rhs } + } + + return false + } + + private static func components(_ version: String) -> [Int] { + version.split(separator: ".").map { Int($0) ?? 0 } + } + } +} + +extension SwiftPM.Deployment { + /// The oldest version the installed SDK can build a platform for. + /// + /// This is how SwiftPM answers the question for a platform it has no floor + /// for: the deployment target of the `XCTest` the SDK ships is the oldest + /// version that SDK supports. A platform the toolchain does not have stays at + /// SwiftPM's own floor. + static func sdkFloor(platform: String) async -> String? { + guard let sdk = sdkName[platform] else { return nil } + + guard + let platformPath = try? await run("xcrun", ["--sdk", sdk, "--show-sdk-platform-path"]), + !platformPath.isEmpty + else { + return nil + } + + let binary = Path(platformPath.trimmingCharacters(in: .whitespacesAndNewlines)) + + "Developer/Library/Frameworks/XCTest.framework/XCTest" + guard binary.exists else { return nil } + + guard let build = try? await run("xcrun", ["vtool", "-show-build", binary.string]) else { + return nil + } + + /// `vtool` prints the load command as `platform IOS` followed by + /// `minos 15.0`. + var seen = false + for line in build.split(separator: "\n") { + let statement = line.trimmingCharacters(in: .whitespaces) + if statement.hasPrefix("platform ") { + seen = statement.hasSuffix(platformName[platform] ?? "") + continue + } + if seen, statement.hasPrefix("minos ") { + return String(statement.dropFirst("minos ".count)) + } + } + + return nil + } + + /// The name a manifest gives the platform an SDK builds for. + static func platform(of sdk: SDK) -> String? { + switch sdk { + case .iOS: + return "ios" + case .macOS: + return "macos" + case .tvOS: + return "tvos" + case .watchOS: + return "watchos" + case .driverKit: + return "driverkit" + case .auto: + return nil + } + } + + private static let sdkName: [String: String] = [ + "macos": "macosx", + "maccatalyst": "macosx", + "ios": "iphoneos", + "tvos": "appletvos", + "watchos": "watchos", + "visionos": "xros", + ] + + private static let platformName: [String: String] = [ + "macos": "MACOS", + "maccatalyst": "MACCATALYST", + "ios": "IOS", + "tvos": "TVOS", + "watchos": "WATCHOS", + "visionos": "XROS", + ] + + private static func run(_ executable: String, _ arguments: [String]) async throws -> String { + let result = try await Subprocess.run( + .name(executable), + arguments: Arguments(arguments), + output: .string(limit: 1024 * 1024)) + + guard result.terminationStatus.isSuccess else { return "" } + return result.standardOutput ?? "" + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Executable.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Executable.swift new file mode 100644 index 0000000..690ab80 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Executable.swift @@ -0,0 +1,68 @@ +// +// SwiftPM+Executable.swift +// +// +// Rules for a command line tool a package builds. +// + +import BazelRules +import Foundation +@preconcurrency import PathKit +import Starlark +import Util + +extension SwiftPM.Generator { + /// An executable target becomes a `swift_binary`: it has a `main`, so it links + /// instead of being linked, and a library rule would neither produce a tool nor + /// compile a top-level `main.swift`. + func buildExecutable( + _ target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + prefix: String, + generated: [String], + resources: ResourceBundle?, + builder: CodeBuilder) + { + builder.load(loadableRule: Rules.Swift.swift_binary) + builder.call( + Rules.Swift.Call.swift_binary( + name: ruleName(of: target.name, in: package), + copts: copts(of: target).nonEmpty, + deps: deps(of: target, in: package).nonEmpty.map { labels in + .build { labels } + }, + linkopts: linkopts(of: target).nonEmpty, + module_name: Self.moduleName(target.name), + srcs: Starlark.glob( + matching( + sources(of: target, prefix: prefix, extensions: ["swift"]), + relativeFiles(of: target, in: package, prefix: prefix)) + + generated + + (resources?.accessors ?? []), + exclude: excluded(target, prefix: prefix), + allowEmpty: true), + tags: Self.manual, + visibility: .public)) + } + + /// An executable product is the tool under the name a consumer writes, so it + /// aliases the target rather than wrapping it: two `swift_binary` rules over + /// the same sources would build the tool twice. + func buildExecutable( + _ product: SwiftPM.PackageProduct, + emitted: Set, + package: SwiftPM.Package, + builder: CodeBuilder) + { + guard let target = product.targets.first(where: emitted.contains) else { return } + let rule = ruleName(of: target, in: package) + guard rule != product.name else { return } + + builder.call( + Rules.Builtin.Call.alias( + name: product.name, + actual: .named(":\(rule)"), + tags: Self.manual, + visibility: .public)) + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift new file mode 100644 index 0000000..804e43a --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Generator.swift @@ -0,0 +1,937 @@ +// +// SwiftPM+Generator.swift +// +// +// Bazel rules for the Swift packages a project depends on. +// + +import BazelRules +import Foundation +@preconcurrency import PathKit +import Starlark +import Util + +extension SwiftPM { + /// Writes one `BUILD` per package under `Packages/`, plus the source tree it + /// points at. + /// + /// The layout matches what `Targets/` already does: a symlink tree of the + /// sources and a generated `BUILD` beside it. Nothing outside `Packages/` + /// changes — a target reaches a product through the facade either way. + final class Generator { + let output: Path + let workspace: Workspace + + let deployment: Deployment + + /// What this project's own packages' build tool plugins wrote, which the + /// generator runs itself before it writes any rule. + private(set) var pluginOutputs = PluginOutputs() + + private var kinds: [String: [String: TargetKind]] = [:] + + /// What a caller tells the user about: where the build differs from what + /// the package asked for, and why. + private(set) var notes: [String] = [] + + /// The plugins and tools Bazel already built, by target name. Empty + /// while a workspace is being generated — nothing has been built yet — + /// and filled by `//:plugins`, which has Bazel build them first. + let built: BuiltPrograms + + init( + output: Path, + workspace: Workspace, + deployment: Deployment, + built: BuiltPrograms = .init()) + { + self.output = output + self.workspace = workspace + self.deployment = deployment + self.built = built + } + + struct BuiltPrograms { + let plugins: [String: Path] + let tools: [String: Path] + + init(plugins: [String: Path] = [:], tools: [String: Path] = [:]) { + self.plugins = plugins + self.tools = tools + } + } + + func generate(locals: [Path] = []) async throws { + pluginOutputs = await runPlugins() + notes.append(contentsOf: pluginOutputs.notes) + + for package in workspace.packages { + kinds[package.directory] = try supportedTargets(of: package) + report(deploymentOf: package) + report(pluginsOf: package) + } + + for package in workspace.packages { + try generate(package) + } + + try writePluginRunner(locals: locals) + } + + /// A package that declares a platform version the project does not reach is + /// compiled at the project's version anyway, and fails in whichever newer + /// API it uses. The reason is in the manifest, not in that error, so it is + /// said out loud. + private func report(deploymentOf package: Package) { + for unmet in deployment.unmet(package) { + let message = """ + \(package.directory) declares \(unmet.platform) \(unmet.required), \ + and the project builds \(unmet.platform) \(unmet.project): \ + the package is compiled at \(unmet.project) and may not support it. + """ + + Log.codeGenerate.warning("\(message, privacy: .public)") + notes.append(message) + } + } + + /// A dependency's build tool plugin is not run. + /// + /// The plugins of a package in the project's own repository are run while + /// the workspace is generated; a dependency's are not, because running one + /// costs a SwiftPM build of its package. Every plugin in the corpus is a + /// linter, which produces no source: a build without it is the same build. + /// One that generates source would leave a target missing the files it + /// expects, and that compile error says nothing about a plugin, so the + /// plugin is named here instead. + private func report(pluginsOf package: Package) { + guard !package.isRoot, !package.isLocal else { return } + + let used = package.manifest.targets + .filter { $0.type != "test" } + .flatMap(\.pluginUsages) + .map(\.name) + + for plugin in Set(used).sorted() { + let message = """ + \(package.directory) asks for the \(plugin) plugin, which is not run: \ + a linter changes nothing, a plugin that generates source does. + """ + + Log.codeGenerate.warning("\(message, privacy: .public)") + notes.append(message) + } + } + + // MARK: Private + + var packagesRoot: Path { + output + PluginSwiftPM.packagesDirectory + } + + private func generate(_ package: Package) throws { + let root = packagesRoot + package.directory + try root.mkpath() + + let builder = CodeBuilder() + var emitted = kinds[package.directory] ?? [:] + + /// A plugin of a package this project owns is built by Bazel, so + /// `//:plugins` can run it without SwiftPM having to load — let + /// alone build — the package it lives in. + if package.isRoot || package.isLocal { + let used = Set(package.manifest.targets.flatMap(\.pluginUsages).map(\.name)) + for target in package.manifest.targets + where target.type == "plugin" && used.contains(target.name) + { + guard let prefix = try materialize(target, in: package, at: root) else { continue } + buildPlugin(target, in: package, prefix: prefix, builder: builder) + } + } + + for target in package.manifest.targets { + guard let kind = emitted[target.name] else { continue } + + if case .binary = kind { + if try !buildBinary(target, in: package, root: root, builder: builder) { + emitted[target.name] = nil + } + continue + } + + guard let prefix = try materialize(target, in: package, at: root) else { continue } + + if case .system = kind { + if !buildSystemLibrary( + target, + in: package, + prefix: prefix, + root: root, + builder: builder) + { + emitted[target.name] = nil + } + continue + } + + let generated = try materialize( + pluginOutputsOf: target, + in: package, + at: root, + kind: kind) + + let resources = try buildResources( + target, + in: package, + prefix: prefix, + root: root, + kind: kind, + generated: generated.resources, + builder: builder) + + switch kind { + case .macro: + buildMacro( + target, + in: package, + prefix: prefix, + generated: generated.sources, + builder: builder) + case .executable: + buildExecutable( + target, + in: package, + prefix: prefix, + generated: generated.sources, + resources: resources, + builder: builder) + case .test: + buildTest( + target, + in: package, + prefix: prefix, + generated: generated.sources, + resources: resources, + builder: builder) + case .swift: + build( + target, + in: package, + prefix: prefix, + generated: generated.sources, + resources: resources, + builder: builder) + case .clang: + buildClang( + target, + in: package, + prefix: prefix, + root: root, + generated: generated, + resources: resources, + builder: builder) + case .binary, .system, .unsupported: + continue + } + } + + for product in package.manifest.products { + build(product, emitted: Set(emitted.keys), package: package, builder: builder) + } + + try (root + "BUILD").write(builder.build()) + } + + /// The targets that can be generated, after dropping everything that depends + /// on one that cannot: a library missing a target it links is worse than a + /// library that is not there at all. + private func supportedTargets(of package: Package) throws -> [String: TargetKind] { + /// Which targets are generated is `kind(of:)`'s answer, tests included: + /// the package under the tool gets its tests, one a project depends on + /// does not. + let targets = package.manifest.targets + var supported: [String: TargetKind] = [:] + + for target in targets { + guard let kind = try kind(of: target, in: package) else { continue } + switch kind { + case .swift, .clang, .binary, .system, .macro, .executable, .test: + supported[target.name] = kind + case .unsupported(let reason): + Log.codeGenerate.warning(""" + Skip \(package.directory, privacy: .public)/\(target.name, privacy: .public): \ + \(reason, privacy: .public) + """) + } + } + + let names = Set(targets.map(\.name)) + var changed = true + while changed { + changed = false + for target in targets where supported[target.name] != nil { + let missing = target.dependencies.compactMap { dependency -> String? in + switch dependency.kind { + case .target(let name), .byName(let name): + guard names.contains(name), supported[name] == nil else { return nil } + return name + case .product: + return nil + } + } + guard let first = missing.first else { continue } + + supported[target.name] = nil + changed = true + Log.codeGenerate.warning(""" + Skip \(package.directory, privacy: .public)/\(target.name, privacy: .public): \ + depends on \(first, privacy: .public), which is not generated + """) + } + } + + return supported + } + + /// What a plugin generated, linked next to the package's rules. + /// + /// Split the way SwiftPM splits it: a file whose extension the target + /// compiles is a source of that target, anything else is one of its + /// resources. A header is neither compiled nor bundled — it is an input of + /// the generated source that includes it, which is the only thing SwiftPM + /// lets reach it too. + struct PluginGenerated { + let sources: [String] + let headers: [String] + let resources: [String] + + static let none = PluginGenerated(sources: [], headers: [], resources: []) + } + + /// What a plugin wrote for a target, split the way the target's own rule + /// takes it, as patterns rather than names. + /// + /// The files are already where they belong: the host gave the plugin + /// this directory to write into, so nothing is moved or linked here — + /// they are real files of the package's `Generated/`, and the output + /// stands without the package's `.build`. + /// + /// What they are called is the plugin's business and changes when the + /// plugin does, so the rules name the directory and the kinds of file in + /// it, never a file. `bazel run //:plugins` writes a new set into the + /// same place and the rules still hold. + func materialize( + pluginOutputsOf target: PackageTarget, + in package: Package, + at root: Path, + kind: TargetKind) throws -> PluginGenerated + { + /// A target that asks for no plugin has no such directory, and a + /// pattern for one would be a pattern for something that is never + /// coming. + guard !target.pluginUsages.isEmpty else { return .none } + + let directory = "Generated/\(target.name)Plugin" + + /// What the target's own rule compiles; a Swift target compiles Swift, + /// and a C-family one whatever clang takes. + let compiled: Set = { + if case .clang = kind { return Set(Self.compileExtensions) } + return ["swift"] + }() + + /// The kinds the target could compile, whether or not the plugin has + /// run yet: the rules are written once and the files arrive later, + /// from `bazel run //:plugins`. + var sources = compiled + var headers: Set = { + if case .clang = kind { return Set(Self.headerExtensions) } + return [] + }() + var resources: Set = [] + var named: [String] = [] + + let output = pluginOutputs.output(of: target.name, in: package) + let base = output?.root.normalize().string ?? "" + for file in output?.files ?? [] { + let relative = file.normalize().string + .delete(prefix: base) + .trimmingCharacters(in: ["/"]) + guard !relative.isEmpty else { continue } + + /// A file with no extension is the one thing a pattern cannot + /// stand for, so that one is named. + guard let `extension` = file.extension, !`extension`.isEmpty else { + named.append("\(directory)/\(relative)") + continue + } + + if compiled.contains(`extension`) { + sources.insert(`extension`) + } else if Self.headerExtensions.contains(`extension`) { + headers.insert(`extension`) + } else { + resources.insert(`extension`) + } + } + + func patterns(_ extensions: Set) -> [String] { + extensions.sorted().map { "\(directory)/**/*.\($0)" } + } + + return .init( + sources: patterns(sources), + headers: patterns(headers), + resources: patterns(resources) + named) + } + + /// The sources stay where SwiftPM put them; the package directory carries + /// one link per target, the way a target's `Sources/` does. + /// + /// A link per target rather than one for the whole checkout is what keeps + /// the rest of the checkout out of the build: a package can ship `BUILD` + /// files of its own — swift-syntax and Yams both do — and Bazel would load + /// them as packages of this workspace. + private func materialize(_ target: PackageTarget, in package: Package, at root: Path) throws -> String? { + guard let directory = sourceDirectory(of: target, in: package) else { return nil } + + let prefix = "\(Self.sourcesRoot)/\(target.name)" + let link = root + prefix + try link.parent().mkpath() + if link.isSymlink || link.exists { + try? link.delete() + } + + /// One link for the whole directory is what a target's sources are, but + /// a package can keep a symlink pointing back into that directory — + /// GRDB's test fixtures do — and Bazel cannot glob through the cycle. + /// Such a tree is mirrored instead, entry by entry, without the link + /// that closes the loop. + if Self.hasCycle(directory) { + try Self.mirror(directory, at: link) + } else { + try link.symlink(directory) + } + + return prefix + } + + /// Whether anything under the directory links back into it. + private static func hasCycle(_ directory: Path) -> Bool { + let root = directory.url.resolvingSymlinksInPath().path + + for entry in entries(of: directory) where entry.isSymlink { + let resolved = entry.url.resolvingSymlinksInPath().path + if root == resolved || root.hasPrefix("\(resolved)/") { return true } + } + + return false + } + + /// A copy of the directory's shape, with one link per file. + private static func mirror(_ directory: Path, at destination: Path) throws { + try destination.mkpath() + + let root = directory.url.resolvingSymlinksInPath().path + for child in (try? directory.children()) ?? [] { + let target = destination + child.lastComponent + + if child.isSymlink { + let resolved = child.url.resolvingSymlinksInPath().path + /// The link that closes the loop; SwiftPM ignores it too. + if root == resolved || root.hasPrefix("\(resolved)/") { continue } + } + + if child.isDirectory { + try mirror(child, at: target) + } else { + try target.symlink(child) + } + } + } + + /// Everything under a directory, links included and not followed. + private static func entries(of directory: Path) -> [Path] { + let children = (try? directory.children()) ?? [] + + return children.flatMap { child -> [Path] in + guard !child.isSymlink, child.isDirectory else { return [child] } + return [child] + entries(of: child) + } + } + + static let sourcesRoot = "Sources" + + /// A package rule is built through the bundle rule that transitions it to a + /// platform; on its own an iOS-only package would be compiled for the host, + /// so no wildcard pattern may pick one up. + static let manual = ["manual"] + + enum TargetKind { + case swift + case clang + case binary + case system + /// A macro: a program the compiler loads, not a library the target links. + case macro + /// A command line tool the package builds. + case executable + /// A test suite, generated for the package the tool was pointed at. + case test + case unsupported(String) + } + + /// What the target is made of, decided by the files on disk: the manifest + /// only says `regular`. + private func kind(of target: PackageTarget, in package: Package) throws -> TargetKind? { + switch target.type { + case "test": + /// Only the package under the tool: the tests of a package a project + /// depends on say nothing about the project. + return package.isRoot ? .test : nil + case "plugin": + /// A plugin is a program SwiftPM runs, never a rule this workspace + /// builds: a command plugin runs when someone asks for it by name, + /// and a build tool plugin runs while the workspace is generated. + /// Whether it ran is what the run reports. + return nil + case "binary": + return .binary + case "system": + return .system + case "macro": + return .macro + case "executable", "snippet": + /// A tool the package builds: it has a `main`, so it links rather + /// than being linked. + return .executable + default: + break + } + + guard sourceDirectory(of: target, in: package) != nil else { + return .unsupported("no source directory") + } + + let extensions = extensions(of: target, in: package) + guard !extensions.isEmpty else { + return .unsupported("no sources") + } + + /// A target with any Swift in it is a Swift target: SwiftPM does not + /// allow one target to mix languages, so the C-family files that are + /// still on disk belong to another target or are excluded. + return extensions.contains("swift") ? .swift : .clang + } + + /// The extensions of the files that actually belong to the target, which is + /// what decides whether a `regular` target is Swift or C-family. + func extensions(of target: PackageTarget, in package: Package) -> Set { + Set(sourceFiles(of: target, in: package).compactMap(\.extension)) + } + + /// The files SwiftPM compiles for the target: what an explicit `sources` + /// list names, or the whole target directory, minus `exclude`. + func sourceFiles(of target: PackageTarget, in package: Package) -> [Path] { + guard let directory = sourceDirectory(of: target, in: package) else { return [] } + let roots = (target.sources?.nonEmpty?.map { directory + $0 }) ?? [directory] + return files(under: roots, excluding: target.exclude, in: directory) + } + + /// The rule that stands for a target. + /// + /// A product may carry the name of a target while grouping several of them. + /// SwiftPM allows that; two rules cannot share one name, so the product + /// keeps the name a consumer writes and the target's own rule is suffixed. + func ruleName(of target: String, in package: Package) -> String { + let grouped = package.manifest.products + .filter { $0.kind == .library && $0.targets.count > 1 } + .map(\.name) + + return grouped.contains(target) ? "\(target)_target" : target + } + + /// Every file under a directory. + /// + /// `FileManager.subpathsOfDirectory` returns nothing when the directory + /// itself is a symlink, and a package can point one target at another's + /// sources that way to build a variant of it. Paths stay under the + /// directory as named, because that is what a glob pattern is built from. + static func walk(_ directory: Path) -> [Path] { + var visited: Set = [] + return walk(directory, visited: &visited) + } + + private static func walk(_ directory: Path, visited: inout Set) -> [Path] { + /// A package's test fixtures can link a directory back to an ancestor, + /// which would otherwise be walked forever. + let resolved = directory.url.resolvingSymlinksInPath().path + guard visited.insert(resolved).inserted else { return [] } + + let children = (try? directory.children()) ?? [] + return children.flatMap { child -> [Path] in + child.isDirectory ? walk(child, visited: &visited) : [child] + } + } + + /// The target's files as paths under its source link, which is what a glob + /// pattern is matched against. + func relativeFiles( + of target: PackageTarget, + in package: Package, + prefix: String, + excluding exclude: Bool = true) -> [String] + { + guard let directory = sourceDirectory(of: target, in: package) else { return [] } + let root = directory.normalize().string + let files = exclude + ? allFiles(of: target, in: package) + : Self.walk(directory) + + return files.compactMap { file in + let path = file.normalize().string + guard path.hasPrefix(root) else { return nil } + return prefix + String(path.dropFirst(root.count)) + } + } + + /// The patterns that match at least one of the target's files. + /// + /// Bazel fails a glob that matches nothing, so a pattern for a file type + /// the target does not have would break the package rather than produce an + /// empty list. + func matching(_ patterns: [String], _ files: [String]) -> [String] { + patterns.filter { pattern in + files.contains { Self.matches(pattern, $0) } + } + } + + /// Bazel's own glob semantics, on path segments: `**` stands for any run + /// of segments, `*` for any part of one. + static func matches(_ pattern: String, _ file: String) -> Bool { + matches( + pattern: pattern.split(separator: "/").map(String.init), + file: file.split(separator: "/").map(String.init)) + } + + private static func matches(pattern: [String], file: [String]) -> Bool { + guard let segment = pattern.first else { return file.isEmpty } + + if segment == "**" { + let rest = Array(pattern.dropFirst()) + if matches(pattern: rest, file: file) { return true } + guard !file.isEmpty else { return false } + return matches(pattern: pattern, file: Array(file.dropFirst())) + } + + guard let name = file.first, matches(segment: segment, name: name) else { + return false + } + return matches(pattern: Array(pattern.dropFirst()), file: Array(file.dropFirst())) + } + + private static func matches(segment: String, name: String) -> Bool { + let parts = segment.split(separator: "*", omittingEmptySubsequences: false).map(String.init) + guard parts.count > 1 else { return segment == name } + + var rest = Substring(name) + for (index, part) in parts.enumerated() where !part.isEmpty { + if index == 0 { + guard rest.hasPrefix(part) else { return false } + rest = rest.dropFirst(part.count) + } else if index == parts.count - 1 { + guard rest.hasSuffix(part) else { return false } + rest = rest.dropLast(part.count) + } else { + guard let range = rest.range(of: part) else { return false } + rest = rest[range.upperBound...] + } + } + + return true + } + + /// Everything in the target directory, `exclude` aside. + /// + /// An explicit `sources` list only stops SwiftPM from compiling the rest; + /// a header next to those sources is still the target's header, which is + /// why it is collected from the whole directory. + func allFiles(of target: PackageTarget, in package: Package) -> [Path] { + guard let directory = sourceDirectory(of: target, in: package) else { return [] } + return files(under: [directory], excluding: target.exclude, in: directory) + } + + private func files(under roots: [Path], excluding exclude: [String], in directory: Path) -> [Path] { + let excluded = exclude.map { (directory + $0).normalize().string } + + var files: [Path] = [] + for root in roots { + if root.isDirectory { + files.append(contentsOf: Self.walk(root)) + } else if root.exists { + files.append(root) + } + } + + return files.filter { file in + let path = file.normalize().string + return !excluded.contains { path == $0 || path.hasPrefix("\($0)/") } + } + } + + /// Extensions a C-family compiler is handed. + static let compileExtensions = ["c", "cc", "cpp", "cxx", "m", "mm", "S", "s"] + /// Extensions that are only ever included by another file. + static let headerExtensions = ["h", "hh", "hpp", "hxx", "inc"] + + /// SwiftPM's own layout rules: an explicit `path`, else one of the + /// conventional directories, else the package root for a single target. + func sourceDirectory(of target: PackageTarget, in package: Package) -> Path? { + if let path = target.path { + let directory = (package.root + path).normalize() + return directory.exists ? directory : nil + } + + /// A test target is looked for under `Tests` first, the way SwiftPM + /// looks for it, and a plugin under `Plugins`. + let conventional = ["Sources", "Source", "src", "srcs"] + let candidates: [String] + switch target.type { + case "test": + candidates = ["Tests"] + conventional + case "plugin": + candidates = ["Plugins"] + conventional + default: + candidates = conventional + } + + for candidate in candidates { + let directory = package.root + candidate + target.name + if directory.exists { return directory } + } + + let flat = package.root + target.name + return flat.exists ? flat : nil + } + + private func build( + _ target: PackageTarget, + in package: Package, + prefix: String, + generated: [String], + resources: ResourceBundle?, + builder: CodeBuilder) + { + builder.load(loadableRule: Rules.Swift.swift_library) + builder.call( + Rules.Swift.Call.swift_library( + name: ruleName(of: target.name, in: package), + /// SwiftPM compiles every package target with the developer + /// search paths, which is how a test-support library finds + /// XCTest. + always_include_developer_search_paths: true, + copts: copts(of: target).nonEmpty, + module_name: Self.moduleName(target.name), + /// Which targets `package` visibility reaches: every target of + /// the same package, which is what the name identifies. + package_name: package.manifest.name, + plugins: plugins(of: target, in: package).nonEmpty.map { macros in + .build { macros } + }, + srcs: Starlark.glob( + matching( + sources(of: target, prefix: prefix, extensions: ["swift"]), + relativeFiles(of: target, in: package, prefix: prefix)) + + generated + + (resources?.accessors ?? []), + exclude: excluded(target, prefix: prefix), + /// The plugin's directory is globbed before anything has + /// written into it: `bazel run //:plugins` does that, and + /// a package that cannot load cannot run it. + allowEmpty: true), + deps: deps(of: target, in: package).nonEmpty.map { labels in + .build { labels } + }, + data: resources.map { bundle in + .build { [Starlark.Label.named(bundle.label)] } + }, + linkopts: linkopts(of: target).nonEmpty, + tags: Self.manual, + visibility: .public)) + } + + /// An explicit `sources` list names files or directories; without one the + /// whole target directory is the target. + func sources(of target: PackageTarget, prefix: String, extensions: [String]) -> [String] { + guard let sources = target.sources, !sources.isEmpty else { + return extensions.map { "\(prefix)/**/*.\($0)" } + } + + return sources.flatMap { source -> [String] in + guard let fileExtension = Path(source).extension else { + return extensions.map { "\(prefix)/\(source)/**/*.\($0)" } + } + return extensions.contains(fileExtension) ? ["\(prefix)/\(source)"] : [] + } + } + + /// `exclude` names a file or a directory; a directory excludes everything + /// under it. + /// + /// Documentation catalogues are excluded on top of that: SwiftPM ignores a + /// `.docc` directory, and the sample code inside one does not compile — + /// it is written against `PackageDescription`. + func excluded(_ target: PackageTarget, prefix: String) -> [String] { + target.exclude.flatMap { excluded -> [String] in + Path(excluded).extension == nil + ? ["\(prefix)/\(excluded)/**"] + : ["\(prefix)/\(excluded)"] + } + Self.ignoredExtensions.map { "\(prefix)/**/*.\($0)/**" } + } + + /// Directory types SwiftPM's file rules ignore. + static let ignoredExtensions = ["docc", "xcprivacy"] + + /// The macros a target loads: a macro target is a program the compiler + /// runs, so it belongs in `plugins` rather than in `deps`. + func plugins(of target: PackageTarget, in package: Package) -> [Starlark.Label] { + let macros = package.manifest.targets.filter { other in + if case .macro = kinds[package.directory]?[other.name] { return true } + return false + }.map(\.name) + + let names = target.dependencies.compactMap { dependency -> String? in + switch dependency.kind { + case .target(let name), .byName(let name): + return macros.contains(name) ? name : nil + case .product: + return nil + } + } + + return Set(names).sorted().map { name in + Starlark.Label.named(":\(ruleName(of: name, in: package))") + } + } + + func deps(of target: PackageTarget, in package: Package) -> [Starlark.Label] { + let localTargets = Set(package.manifest.targets.map(\.name)) + let localProducts = Dictionary( + package.manifest.products.map { ($0.name, $0) }, + uniquingKeysWith: { first, _ in first }) + + let labels: [String] = target.dependencies.compactMap { dependency in + switch dependency.kind { + case .target(let name): + guard localTargets.contains(name), !isMacro(name, in: package) else { return nil } + return ":\(ruleName(of: name, in: package))" + case .byName(let name): + if localTargets.contains(name) { + guard !isMacro(name, in: package) else { return nil } + return ":\(ruleName(of: name, in: package))" + } + if localProducts[name] != nil { return ":\(name)" } + return label(product: name, package: nil, from: package) + case .product(let name, let packageName): + return label(product: name, package: packageName, from: package) + } + } + + return Array(Set(labels)).sorted().map(Starlark.Label.named) + } + + private func isMacro(_ target: String, in package: Package) -> Bool { + if case .macro = kinds[package.directory]?[target] { return true } + return false + } + + /// A product of another package is reached through the facade, so the label + /// does not depend on how that package's rules are generated. + private func label(product: String, package name: String?, from package: Package) -> String? { + guard let owner = self.package(ofProduct: product, package: name, from: package) else { + Log.codeGenerate.warning(""" + No package for product \(product, privacy: .public) \ + required by \(package.directory, privacy: .public) + """) + return nil + } + + return "//\(PluginSwiftPM.packagesDirectory)/\(owner.directory):\(product)" + } + + /// Which package declares a product: the one the dependency names, or the + /// one whose identity matches. + private func package( + ofProduct product: String, + package name: String?, + from package: Package) -> Package? + { + let identities = [name, product].compactMap { $0 } + + package.manifest.dependencies.map(\.identity) + + for identity in identities { + guard let directory = workspace.directoryByIdentity[identity.lowercased()] else { continue } + return workspace.packages.first { $0.directory == directory } + } + + return nil + } + + private func build( + _ product: PackageProduct, + emitted: Set, + package: Package, + builder: CodeBuilder) + { + switch product.kind { + case .library: + break + case .executable: + buildExecutable(product, emitted: emitted, package: package, builder: builder) + return + case .plugin: + return + } + + /// A macro is not part of a product a consumer links: it is loaded by + /// the compiler of whatever declares the macro, inside its own package. + let targets = product.targets.filter { target in + emitted.contains(target) && !isMacro(target, in: package) + } + guard !targets.isEmpty else { return } + + /// A product of one target is that target under another name; several + /// targets are a group that exports all of them. + if targets.count == 1, let target = targets.first { + guard target != product.name else { return } + + builder.call( + Rules.Builtin.Call.alias( + name: product.name, + actual: .named(":\(ruleName(of: target, in: package))"), + tags: Self.manual, + visibility: .public)) + return + } + + builder.load(loadableRule: Rules.Swift.swift_library_group) + builder.call( + Rules.Swift.Call.swift_library_group( + name: product.name, + deps: .build { + targets.sorted().map { target in + Starlark.Label.named(":\(ruleName(of: target, in: package))") + } + }, + tags: Self.manual, + visibility: .public)) + } + + /// Swift module names are identifiers; a package name is not. + static func moduleName(_ name: String) -> String { + String(name.map { character in + character.isLetter || character.isNumber || character == "_" ? character : "_" + }) + } + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Macro.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Macro.swift new file mode 100644 index 0000000..992a890 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Macro.swift @@ -0,0 +1,47 @@ +// +// SwiftPM+Macro.swift +// +// +// Rules for a package target the compiler loads instead of linking. +// + +import BazelRules +import Foundation +@preconcurrency import PathKit +import Starlark +import Util + +extension SwiftPM.Generator { + /// A macro target becomes a `swift_compiler_plugin`: a program the compiler + /// runs while it compiles whatever declares the macro. + /// + /// It is built for the machine doing the building rather than the platform the + /// project targets, which is why it cannot be an ordinary library — and why a + /// target that uses the macro lists it in `plugins`, never in `deps`. + func buildMacro( + _ target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + prefix: String, + generated: [String], + builder: CodeBuilder) + { + builder.load(loadableRule: Rules.Swift.swift_compiler_plugin) + builder.call( + Rules.Swift.Call.swift_compiler_plugin( + name: ruleName(of: target.name, in: package), + srcs: Starlark.glob( + matching( + sources(of: target, prefix: prefix, extensions: ["swift"]), + relativeFiles(of: target, in: package, prefix: prefix)) + + generated, + exclude: excluded(target, prefix: prefix), + allowEmpty: true), + copts: copts(of: target).nonEmpty, + deps: deps(of: target, in: package).nonEmpty.map { labels in + .build { labels } + }, + module_name: Self.moduleName(target.name), + tags: Self.manual, + visibility: .public)) + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift new file mode 100644 index 0000000..10e80c9 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Manifest.swift @@ -0,0 +1,308 @@ +// +// SwiftPM+Manifest.swift +// +// +// The subset of `swift package dump-package` the generator needs. +// + +import Foundation + +// MARK: - SwiftPM + +/// Generating Bazel rules for the Swift packages a project depends on. +public enum SwiftPM { } + +extension SwiftPM { + /// A package manifest, as `swift package dump-package` prints it. + /// + /// The dump is the manifest after SwiftPM evaluated it, so conditionals and + /// defaults are already applied; reading it beats re-implementing + /// `Package.swift`. + struct Manifest: Decodable { + let name: String + let platforms: [Platform] + let products: [PackageProduct] + let targets: [PackageTarget] + let dependencies: [Dependency] + let cLanguageStandard: String? + let cxxLanguageStandard: String? + /// `{"_version": "6.0.0"}`: which `PackageDescription` the manifest was + /// written against, which a plugin has to be compiled against too. + let toolsVersion: String + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: AnyKey.self) + name = try container.decode(String.self, forKey: AnyKey("name")) + platforms = container.list(Platform.self, "platforms") + products = container.list(PackageProduct.self, "products") + targets = container.list(PackageTarget.self, "targets") + dependencies = container.list(Dependency.self, "dependencies") + cLanguageStandard = container.value(String.self, "cLanguageStandard") + cxxLanguageStandard = container.value(String.self, "cxxLanguageStandard") + toolsVersion = container.value([String: String].self, "toolsVersion")?["_version"] ?? "5.9.0" + } + } + + struct Platform: Decodable { + let platformName: String + let version: String? + } + + enum ProductKind { + case library + case executable + case plugin + } + + struct PackageProduct: Decodable { + let name: String + let targets: [String] + /// `{"library": ["automatic"]}`, `{"executable": null}`, `{"plugin": null}`. + let type: [String: AnyDecodable?] + + var kind: ProductKind { + if type.keys.contains("executable") { return .executable } + if type.keys.contains("plugin") { return .plugin } + return .library + } + } + + struct PackageTarget: Decodable { + let name: String + /// `regular`, `executable`, `test`, `system`, `binary`, `plugin`, `macro`. + let type: String + let path: String? + let sources: [String]? + let exclude: [String] + let publicHeadersPath: String? + let settings: [Setting] + let resources: [Resource] + let dependencies: [TargetDependency] + /// The plugins the target asks to be run while it is built. + let pluginUsages: [PluginUsage] + /// A binary target's remote archive. + let url: String? + let checksum: String? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: AnyKey.self) + name = try container.decode(String.self, forKey: AnyKey("name")) + type = container.value(String.self, "type") ?? "regular" + path = container.value(String.self, "path") + sources = container.value([String].self, "sources") + exclude = container.list(String.self, "exclude") + publicHeadersPath = container.value(String.self, "publicHeadersPath") + settings = container.list(Setting.self, "settings") + resources = container.list(Resource.self, "resources") + dependencies = container.list(TargetDependency.self, "dependencies") + pluginUsages = container.list(PluginUsage.self, "pluginUsages") + url = container.value(String.self, "url") + checksum = container.value(String.self, "checksum") + } + } + + /// `{"plugin": ["SwiftLint", "SwiftLintPlugin"]}`: the plugin's name first, + /// then the package it comes from, which is absent for one in the same + /// package. + struct PluginUsage: Decodable { + let name: String + let package: String? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: AnyKey.self) + let values = container.value([String?].self, "plugin") + ?? container.value([String?].self, "byName") + ?? [] + + name = values.first.flatMap { $0 } ?? "" + package = values.count > 1 ? values[1] : nil + } + } + + /// `{"tool": "swift", "kind": {"define": {"_0": "FOO"}}}` + struct Setting: Decodable { + let tool: String + let kind: [String: SettingValues] + + /// `define`, `headerSearchPath`, `defaultIsolation`… + var name: String? { + kind.keys.first + } + + var values: [String] { + kind.values.first?.values ?? [] + } + } + + /// `{"_0": "FOO"}`, `{"_0": ["-Xfrontend", "-warn-long"]}` or `{}`. + struct SettingValues: Decodable { + let values: [String] + + init(from decoder: Decoder) throws { + guard let container = try? decoder.container(keyedBy: AnyKey.self) else { + values = [] + return + } + + var result: [String] = [] + for key in container.allKeys.sorted(by: { $0.stringValue < $1.stringValue }) { + if let value = try? container.decode(String.self, forKey: key) { + result.append(value) + } else if let list = try? container.decode([String].self, forKey: key) { + result.append(contentsOf: list) + } + } + values = result + } + } + + /// `{"rule": {"copy": {}}, "path": "Resources"}` + struct Resource: Decodable { + let path: String + let rule: [String: AnyDecodable?] + + var isCopy: Bool { + rule.keys.contains("copy") + } + } + + enum TargetDependencyKind { + /// A target in the same package, or a product with the same name. + case byName(String) + /// A target in the same package. + case target(String) + /// `product: [productName, packageName, moduleAliases, condition]` + case product(name: String, package: String?) + } + + struct TargetDependency: Decodable { + let kind: TargetDependencyKind + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: AnyKey.self) + + for key in container.allKeys { + let values = container.list(AnyDecodable.self, key.stringValue) + let strings = values.compactMap { $0.value as? String } + guard let name = strings.first else { continue } + + switch key.stringValue { + case "byName": + kind = .byName(name) + return + case "target": + kind = .target(name) + return + case "product": + kind = .product(name: name, package: strings.dropFirst().first) + return + default: + continue + } + } + + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, debugDescription: "Unknown target dependency")) + } + } + + /// `{"fileSystem": [{...}]}` or `{"sourceControl": [{...}]}` + struct Dependency: Decodable { + let identity: String + let name: String? + let path: String? + let url: String? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: AnyKey.self) + + for key in container.allKeys { + guard let entry = container.list(DependencyEntry.self, key.stringValue).first else { continue } + + identity = entry.identity + name = entry.nameForTargetDependencyResolutionOnly + path = entry.path + url = entry.location?.url + return + } + + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, debugDescription: "Unknown package dependency")) + } + } + + struct DependencyEntry: Decodable { + let identity: String + let nameForTargetDependencyResolutionOnly: String? + let path: String? + let location: DependencyLocation? + } + + /// `{"remote": [{"urlString": "https://…"}]}` + struct DependencyLocation: Decodable { + let url: String? + + init(from decoder: Decoder) throws { + guard let container = try? decoder.container(keyedBy: AnyKey.self) else { + url = nil + return + } + + for key in container.allKeys { + if let remote = container.list(DependencyRemote.self, key.stringValue).first { + url = remote.urlString + return + } + } + url = nil + } + } + + struct DependencyRemote: Decodable { + let urlString: String + } + + /// The dump uses payload keys (`_0`) and wrapper keys (`byName`), so every + /// container is keyed dynamically. + struct AnyKey: CodingKey { + let stringValue: String + let intValue: Int? = nil + + init(_ value: String) { stringValue = value } + init?(stringValue: String) { self.stringValue = stringValue } + init?(intValue _: Int) { nil } + } +} + +extension KeyedDecodingContainer where Key == SwiftPM.AnyKey { + /// Absent, null and malformed all mean "not there": a manifest dump spans every + /// tools version, and a key that does not apply is simply missing. + func value(_ type: T.Type, _ key: String) -> T? { + try? decodeIfPresent(type, forKey: SwiftPM.AnyKey(key)) + } + + func list(_: T.Type, _ key: String) -> [T] { + (try? decodeIfPresent([T].self, forKey: SwiftPM.AnyKey(key))) ?? [] + } +} + +// MARK: - AnyDecodable + +/// Anything, decoded only to be ignored. +struct AnyDecodable: Decodable { + let value: Any? + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let value = try? container.decode(String.self) { + self.value = value + } else if let value = try? container.decode(Int.self) { + self.value = value + } else if let value = try? container.decode(Bool.self) { + self.value = value + } else { + value = nil + } + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift new file mode 100644 index 0000000..c6ae8dd --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Plugin.swift @@ -0,0 +1,52 @@ +// +// SwiftPM+Plugin.swift +// +// +// The sources a build tool plugin generates. +// + +import Foundation +@preconcurrency import PathKit + +extension SwiftPM { + /// What a build tool plugin produced, by target. + /// + /// bazelize is the plugin's host: it compiles the plugin, hands it the + /// package graph and a directory to write into, and runs the commands the + /// plugin asks for. Where the files go is the host's decision — the + /// package's own `Generated/Plugin` — and what they are called is + /// the plugin's. + /// + /// The consequence is the one every generated file here has: they change + /// when bazelize runs again, not when the input changes. Only a package in + /// the project's own repository is run, because a plugin's tool still has to + /// be built, and building one for every dependency that merely lints would + /// make generating a workspace cost a full build. + struct PluginOutputs: Sendable { + /// What one target's plugins wrote, and the directory they wrote it + /// into: two plugins of the same target write into a directory each, and + /// a prebuild command writes a tree, so a file is only named by where it + /// sits under that root. + struct Output: Sendable { + let root: Path + let files: [Path] + } + + /// What kept a plugin from producing what a target expects, for the run + /// to say out loud: the compile error a missing generated file causes + /// names the file, never the plugin. + let notes: [String] + + /// Keyed `/`. + private let outputs: [String: Output] + + init(outputs: [String: Output] = [:], notes: [String] = []) { + self.outputs = outputs + self.notes = notes + } + + func output(of target: String, in package: Package) -> Output? { + outputs["\(package.directory)/\(target)"] + } + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginContext.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginContext.swift new file mode 100644 index 0000000..bb14bf3 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginContext.swift @@ -0,0 +1,189 @@ +// +// SwiftPM+PluginContext.swift +// +// +// The package graph a plugin is handed. +// + +import Foundation +@preconcurrency import PathKit + +extension SwiftPM { + /// Builds what a plugin is told about the package it is running for. + /// + /// A plugin reads the target it was asked about — its sources, its name, the + /// directory it lives in — and the package around it. That is what this + /// assembles, in the shape SwiftPM's protocol spells: every path interned so + /// a graph of files repeats no directory. + struct PluginContextBuilder { + // MARK: Lifecycle + + init(package: Package, generator: Generator) { + self.package = package + self.generator = generator + } + + // MARK: Internal + + /// Adds the package and every target in it, and answers which id the + /// target being asked about has. + mutating func add(package: Package, asking target: PackageTarget) throws -> Int { + let directoryId = add(path: package.root.absolute().string) + + var targetIds: [Int] = [] + for (index, candidate) in package.manifest.targets.enumerated() { + indexByTarget[candidate.name] = index + targetIds.append(index) + } + + targets = try package.manifest.targets.map { candidate in + try wire(candidate, in: package, sources: candidate.name == target.name) + } + + products = package.manifest.products.map { product in + let ids = product.targets.compactMap { indexByTarget[$0] } + return .init( + name: product.name, + targetIds: ids, + info: product.kind == .executable + ? .executable(mainTargetId: ids.first ?? 0) + : .library) + } + + packages = [ + .init( + identity: package.identity, + displayName: package.manifest.name, + directoryId: directoryId, + origin: package.isRoot ? .root : .local(pathId: directoryId), + toolsVersion: Self.version(package.manifest.toolsVersion), + dependencies: [], + productIds: Array(products.indices), + targetIds: targetIds), + ] + + guard let id = indexByTarget[target.name] else { + throw PluginError.undecodable("the target asked about is not in its own package") + } + + return id + } + + /// Interns a path, answering the id the wire refers to it by. + mutating func add(path: String) -> Int { + if let id = idByPath[path] { return id } + + let id = paths.count + paths.append(.init(baseURLId: nil, subpath: path)) + idByPath[path] = id + return id + } + + func context(workDirectoryId: Int, tools: [String: PluginWire.Tool]) -> PluginWire.InputContext { + .init( + paths: paths, + targets: targets, + products: products, + packages: packages, + xcodeTargets: [], + xcodeProjects: [], + pluginWorkDirId: workDirectoryId, + toolSearchDirIds: [], + accessibleTools: tools) + } + + // MARK: Private + + private let package: Package + private let generator: Generator + + private var paths: [PluginWire.URLNode] = [] + private var idByPath: [String: Int] = [:] + private var indexByTarget: [String: Int] = [:] + private var targets: [PluginWire.Target] = [] + private var products: [PluginWire.Product] = [] + private var packages: [PluginWire.Package] = [] + + /// `6.0.0` as the three numbers the wire wants. + private static func version(_ value: String) -> PluginWire.Package.ToolsVersion { + let parts = value.split(separator: ".").compactMap { Int($0) } + return .init( + major: parts.count > 0 ? parts[0] : 5, + minor: parts.count > 1 ? parts[1] : 9, + patch: parts.count > 2 ? parts[2] : 0) + } + + /// One target, with its files listed only for the target being asked + /// about: a plugin reads those, and walking every target of a package to + /// tell it about files it never looks at is work for nothing. + private mutating func wire( + _ target: PackageTarget, + in package: Package, + sources listed: Bool) throws -> PluginWire.Target + { + let directory = generator.sourceDirectory(of: target, in: package) ?? package.root + let directoryId = add(path: directory.absolute().string) + + let dependencies: [PluginWire.Target.Dependency] = target.dependencies.compactMap { dependency in + switch dependency.kind { + case .target(let name), .byName(let name): + return indexByTarget[name].map { .target($0) } + case .product: + return nil + } + } + + let files: [PluginWire.File] = listed + ? Generator.walk(directory).map { file in + .init( + basePathId: directoryId, + name: file.absolute().string.delete(prefix: directory.absolute().string + "/") ?? file + .lastComponent, + type: Self.type(of: file)) + } + : [] + + return .init( + name: target.name, + directoryId: directoryId, + dependencies: dependencies, + info: Self.info(of: target, module: Generator.moduleName(target.name), sources: files)) + } + + private static func info( + of target: PackageTarget, + module: String, + sources: [PluginWire.File]) -> PluginWire.TargetInfo + { + switch target.type { + case "binary": + return .binary(artifactId: 0) + case "system": + return .system + default: + return .swift(module: module, kind: Self.kind(of: target), sources: sources) + } + } + + /// What SwiftPM calls the kind of a source module. + private static func kind(of target: PackageTarget) -> String { + switch target.type { + case "executable", "snippet": + return "executable" + case "test": + return "test" + case "macro": + return "macro" + default: + return "generic" + } + } + + private static func type(of file: Path) -> String { + let `extension` = file.extension ?? "" + if Generator.headerExtensions.contains(`extension`) { return "header" } + if `extension` == "swift" || Generator.compileExtensions.contains(`extension`) { return "source" } + return "resource" + } + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginHost.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginHost.swift new file mode 100644 index 0000000..8a3b490 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginHost.swift @@ -0,0 +1,258 @@ +// +// SwiftPM+PluginHost.swift +// +// +// Running a package's build tool plugins. +// + +import Foundation +@preconcurrency import PathKit +import Subprocess +import System +import Util + +extension SwiftPM { + /// Runs the build tool plugins of an already generated workspace. + /// + /// Nothing else is generated: the rules are already there and do not change + /// when a plugin writes a different set of files, because they glob the + /// directory the plugin writes into. What comes back is what to tell the + /// user about. + /// `plugins` and `tools` are what Bazel built, by target name: a plugin is + /// a program and so is the tool it runs, and building them is Bazel's job + /// wherever `//:plugins` is what started this. + public static func runPlugins( + output: Path, + locals: [Path], + plugins: [String: Path] = [:], + tools: [String: Path] = [:]) async throws -> [String] + { + let workspace = try await loadWorkspace(output: output, root: nil, locals: locals) + let generator = Generator( + output: output, + workspace: workspace, + deployment: .init(project: [:]), + built: .init(plugins: plugins, tools: tools)) + + return await generator.runPlugins().notes + } +} + +extension SwiftPM.Generator { + /// Runs the build tool plugins of the packages this project owns, into the + /// directory their output belongs in. + /// + /// bazelize is the plugin host here: it compiles the plugin, hands it the + /// package graph and a directory to write into, and runs the commands it + /// asks for. What the plugin writes and what it calls those files is the + /// plugin's business — the host supplies the place, and is told afterwards + /// what landed there. + /// + /// Asking SwiftPM instead means building the whole target the plugin is + /// attached to, which fails for reasons that have nothing to do with the + /// plugin, and which on some toolchains silently does not run the plugin at + /// all for a C-family target. + func runPlugins() async -> SwiftPM.PluginOutputs { + var outputs: [String: SwiftPM.PluginOutputs.Output] = [:] + var notes: [String] = [] + + for package in workspace.packages where package.isRoot || package.isLocal { + for target in package.manifest.targets where !target.pluginUsages.isEmpty { + let directory = pluginWorkDirectory(of: target, in: package) + try? directory.delete() + + var produced = false + for usage in target.pluginUsages { + do { + produced = try await run( + plugin: usage, + on: target, + in: package, + at: directory) || produced + } catch { + notes.append(note(usage, target, package, "\(error)")) + } + } + + guard produced, directory.isDirectory else { continue } + outputs["\(package.directory)/\(target.name)"] = .init( + root: directory, + files: Self.walk(directory).sorted()) + } + } + + return .init(outputs: outputs, notes: notes) + } + + /// Where a target's plugins write: beside the rules of the package that + /// declares it, which is where every other generated file of that package + /// already is. + func pluginWorkDirectory(of target: SwiftPM.PackageTarget, in package: SwiftPM.Package) -> Path { + output + PluginSwiftPM.packagesDirectory + package.directory + "Generated/\(target.name)Plugin" + } + + // MARK: Private + + private func note( + _ usage: SwiftPM.PluginUsage, + _ target: SwiftPM.PackageTarget, + _ package: SwiftPM.Package, + _ reason: String) -> String + { + let message = """ + \(package.directory)/\(target.name) did not run the \(usage.name) plugin: \(reason). \ + Whatever that plugin generates is missing from the target. + """ + Log.codeGenerate.warning("\(message, privacy: .public)") + return message + } + + /// Asks one plugin what to run, and runs it. `true` when it asked for + /// anything at all. + private func run( + plugin usage: SwiftPM.PluginUsage, + on target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + at directory: Path) async throws -> Bool + { + guard let (pluginTarget, pluginPackage) = self.plugin(usage, from: package) else { + throw SwiftPM.PluginError.undecodable("no target named \(usage.name) declares it") + } + + let executable = try await compile(plugin: pluginTarget, in: pluginPackage) + try directory.mkpath() + + let context = try await self.context( + for: target, + in: package, + plugin: pluginTarget, + pluginPackage: pluginPackage, + workDirectory: directory) + + let commands = try await SwiftPM.PluginHost.ask(executable: executable, request: context) + guard !commands.isEmpty else { return false } + + for command in commands { + try await SwiftPM.PluginHost.run(command) + } + + return true + } + + /// The target that implements a plugin, and the package it belongs to: a + /// usage names the plugin, and optionally the package it comes from. + private func plugin( + _ usage: SwiftPM.PluginUsage, + from package: SwiftPM.Package) -> (SwiftPM.PackageTarget, SwiftPM.Package)? + { + let packages: [SwiftPM.Package] + if let name = usage.package { + let directory = workspace.directoryByIdentity[name.lowercased()] + packages = workspace.packages.filter { $0.directory == directory } + } else { + packages = [package] + workspace.packages.filter { $0.directory != package.directory } + } + + for candidate in packages { + if let target = candidate.manifest.targets.first(where: { + $0.name == usage.name && $0.type == "plugin" + }) { + return (target, candidate) + } + } + + return nil + } + + /// Compiles a plugin into a program the host can talk to. + /// + /// A plugin target depends on nothing but the toolchain's `PackagePlugin`, + /// so compiling it needs no package graph — which is the whole reason the + /// host can run one without building anything else. + private func compile(plugin target: SwiftPM.PackageTarget, in package: SwiftPM.Package) async throws -> Path { + /// Bazel built it: `//:plugins` has the plugin as `data`, so it is in + /// the runfiles by the time the host runs. + if let prebuilt = built.plugins[target.name], prebuilt.exists { return prebuilt } + + let built = output + ".bazelize/plugins" + package.directory + target.name + if built.exists { return built } + + guard let directory = sourceDirectory(of: target, in: package) else { + throw SwiftPM.PluginError.compileFailed("no source directory") + } + + let sources = Self.walk(directory).filter { $0.extension == "swift" }.map(\.string) + guard !sources.isEmpty else { + throw SwiftPM.PluginError.compileFailed("no sources") + } + + try built.parent().mkpath() + try await SwiftPM.PluginHost.compile( + sources: sources, + module: target.name, + toolsVersion: package.manifest.toolsVersion, + to: built) + + return built + } + + /// The graph the plugin is given, and the tools it may run. + private func context( + for target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + plugin: SwiftPM.PackageTarget, + pluginPackage: SwiftPM.Package, + workDirectory: Path) async throws -> SwiftPM.PluginWire.Request + { + var builder = SwiftPM.PluginContextBuilder(package: package, generator: self) + let targetId = try builder.add(package: package, asking: target) + let workDirId = builder.add(path: workDirectory.absolute().string) + + var tools: [String: SwiftPM.PluginWire.Tool] = [:] + for dependency in plugin.dependencies { + guard case .target(let name) = dependency.kind else { + guard case .byName(let name) = dependency.kind else { continue } + if let tool = try await self.tool(named: name, in: pluginPackage) { + tools[name] = .init(path: builder.add(path: tool.string), triples: nil) + } + continue + } + + if let tool = try await self.tool(named: name, in: pluginPackage) { + tools[name] = .init(path: builder.add(path: tool.string), triples: nil) + } + } + + return .init( + context: builder.context(workDirectoryId: workDirId, tools: tools), + rootPackageId: 0, + targetId: targetId, + pluginGeneratedSources: [], + pluginGeneratedResources: []) + } + + /// The program a plugin runs, built by SwiftPM because it is an ordinary + /// executable target with ordinary dependencies. + /// + /// Only this one product is built, rather than the target the plugin is + /// attached to: which product holds the tool is read from the manifest here + /// instead of guessed from the target's name, which is what some toolchains + /// get wrong. + private func tool(named name: String, in package: SwiftPM.Package) async throws -> Path? { + guard package.manifest.targets.contains(where: { $0.name == name && $0.type == "executable" }) + else { + return nil + } + + /// Bazel built it, so SwiftPM never has to load the package the tool + /// lives in — which some toolchains cannot do when a plugin names its + /// tool by target. + if let prebuilt = built.tools[name], prebuilt.exists { return prebuilt } + + let product = package.manifest.products.first { product in + product.targets.contains(name) + }?.name ?? name + + return try await SwiftPM.PluginHost.build(product: product, of: package.root) + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginProcess.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginProcess.swift new file mode 100644 index 0000000..fdf70c9 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginProcess.swift @@ -0,0 +1,216 @@ +// +// SwiftPM+PluginProcess.swift +// +// +// Compiling a plugin, talking to it, and running what it asks for. +// + +import Foundation +@preconcurrency import PathKit +import Subprocess +import System +import Util + +extension SwiftPM { + /// The three things a plugin host does with processes. + enum PluginHost { + /// Compiles a plugin target into a program. + /// + /// The only thing it links is the toolchain's `PackagePlugin`, which is + /// what makes running a plugin independent of building anything else. + static func compile( + sources: [String], + module: String, + toolsVersion: String, + to executable: Path) async throws + { + guard let api = pluginAPIPath else { throw PluginError.noToolchain } + + let result = try await Subprocess.run( + .name("swiftc"), + arguments: Arguments([ + "-I", api, + "-L", api, + "-lPackagePlugin", + "-Xlinker", "-rpath", "-Xlinker", api, + /// Which `PackagePlugin` API the plugin was written against; + /// its availability is stated in terms of it. + "-package-description-version", toolsVersion, + "-parse-as-library", + "-module-name", module, + "-o", executable.string, + ] + sources), + output: .discarded, + error: .string(limit: 1024 * 1024)) + + guard result.terminationStatus.isSuccess else { + throw PluginError.compileFailed(Self.errors(result.standardError)) + } + } + + /// Asks a plugin what to run, and collects what it answers. + /// + /// The protocol is a length-prefixed JSON message each way over the + /// plugin's standard input and output; its own printing goes to standard + /// error, which is forwarded as diagnostics. + static func ask(executable: Path, request: PluginWire.Request) async throws -> [PluginWire.Command] { + let payload = try JSONEncoder().encode(request) + + var input = Data() + withUnsafeBytes(of: UInt64(payload.count).littleEndian) { input.append(contentsOf: $0) } + input.append(payload) + + let result = try await Subprocess.run( + .path(FilePath(executable.string)), + input: .data(input), + output: .data(limit: 64 * 1024 * 1024), + error: .string(limit: 1024 * 1024)) + + var commands: [PluginWire.Command] = [] + for response in try messages(in: Data(result.standardOutput)) { + switch response { + case .build(let command): + commands.append(command) + case .prebuild(let command, let directory): + try Path(URL(string: directory)?.path ?? directory).mkpath() + commands.append(command) + case .diagnostic(let severity, let message): + Log.codeGenerate.warning("plugin \(severity, privacy: .public): \(message, privacy: .public)") + case .progress, .unsupported: + continue + } + } + + guard result.terminationStatus.isSuccess || !commands.isEmpty else { + throw PluginError.compileFailed(Self.errors(result.standardError)) + } + + return commands + } + + /// Runs one command a plugin asked for. + static func run(_ command: PluginWire.Command) async throws { + let executable = Self.path(command.executable) + let overrides = command.environment.reduce(into: [Subprocess.Environment.Key: String?]()) { all, entry in + guard let key = Subprocess.Environment.Key(rawValue: entry.key) else { return } + all[key] = entry.value + } + + let result = try await Subprocess.run( + .path(FilePath(executable)), + arguments: Arguments(command.arguments.map(Self.path)), + environment: .inherit.updating(overrides), + workingDirectory: command.workingDirectory.map { FilePath(Self.path($0)) }, + output: .discarded, + error: .string(limit: 1024 * 1024)) + + guard result.terminationStatus.isSuccess else { + throw PluginError.compileFailed( + "\(command.displayName ?? Path(executable).lastComponent): \(Self.errors(result.standardError))") + } + } + + /// Builds the product that holds a plugin's tool. + static func build(product: String, of package: Path) async throws -> Path? { + let build = try await Subprocess.run( + .name("swift"), + arguments: Arguments(["build", "--package-path", package.string, "--product", product]), + output: .discarded, + error: .string(limit: 1024 * 1024)) + + guard build.terminationStatus.isSuccess else { + throw PluginError.compileFailed("the plugin's tool does not build: \(Self.errors(build.standardError))") + } + + let directory = try await Subprocess.run( + .name("swift"), + arguments: Arguments(["build", "--package-path", package.string, "--show-bin-path"]), + output: .string(limit: 64 * 1024), + error: .discarded) + + guard + let path = Optional(directory.standardOutput.trimmingCharacters(in: .whitespacesAndNewlines)), + !path.isEmpty + else { + return nil + } + + let tool = Path(path) + product + return tool.exists ? tool : nil + } + + // MARK: Private + + /// A plugin speaks in file URLs; a command line takes paths. + private static func path(_ value: String) -> String { + guard value.hasPrefix("file://") else { return value } + return URL(string: value)?.path ?? value + } + + private static func messages(in data: Data) throws -> [PluginWire.Response] { + var responses: [PluginWire.Response] = [] + var offset = data.startIndex + + while offset + 8 <= data.endIndex { + let header = data[offset ..< offset + 8] + let count = Int(header.reduce(UInt64(0)) { total, byte in + (total >> 8) | (UInt64(byte) << 56) + }.littleEndian) + + let start = offset + 8 + guard count > 0, start + count <= data.endIndex else { + throw PluginError.undecodable("a message claims \(count) bytes and the stream has fewer") + } + + let payload = data[start ..< start + count] + do { + responses.append(try JSONDecoder().decode(PluginWire.Response.self, from: payload)) + } catch { + throw PluginError.undecodable("\(error)") + } + + offset = start + count + } + + return responses + } + + /// Where the toolchain keeps the module a plugin is compiled against, + /// which the rules that build a plugin need spelled out. + /// + /// `xcrun` is asked once: a run builds against one toolchain. + static let pluginAPIPath: String? = { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["--find", "swiftc"] + + let output = Pipe() + process.standardOutput = output + process.standardError = FileHandle.nullDevice + + guard (try? process.run()) != nil else { return nil } + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + let found = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !found.isEmpty else { return nil } + + /// `/usr/bin/swiftc` → `/usr/lib/swift/pm/PluginAPI` + let api = Path(found).parent().parent() + "lib/swift/pm/PluginAPI" + return api.isDirectory ? api.string : nil + }() + + private static func errors(_ output: String?) -> String { + guard let output else { return "no output" } + + let lines = output + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { $0.lowercased().hasPrefix("error:") } + + let reason = lines.suffix(3).joined(separator: " ") + return reason.isEmpty ? output.suffix(400).trimmingCharacters(in: .whitespacesAndNewlines) : reason + } + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginRule.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginRule.swift new file mode 100644 index 0000000..b4d88ba --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginRule.swift @@ -0,0 +1,165 @@ +// +// SwiftPM+PluginRule.swift +// +// +// Building a build tool plugin, and the command that runs it. +// + +import BazelRules +import Foundation +@preconcurrency import PathKit +import Starlark +import Util + +extension SwiftPM.Generator { + /// A build tool plugin as a program Bazel builds. + /// + /// A plugin links nothing but the toolchain's `PackagePlugin`, so this is an + /// ordinary `swift_binary` with the module on its search path. Building it + /// here rather than with SwiftPM is what keeps the plugin path independent + /// of whether the package it lives in builds at all — some toolchains + /// cannot even load a package whose plugin names its tool by target. + func buildPlugin( + _ target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + prefix: String, + builder: CodeBuilder) + { + guard let api = SwiftPM.PluginHost.pluginAPIPath else { return } + + builder.load(loadableRule: Rules.Swift.swift_binary) + builder.call( + Rules.Swift.Call.swift_binary( + name: ruleName(of: target.name, in: package), + copts: [ + "-I", api, + /// Which `PackagePlugin` the plugin was written against; its + /// availability is stated in terms of the tools version. + "-package-description-version", package.manifest.toolsVersion, + ], + linkopts: [ + "-L", api, + "-lPackagePlugin", + "-Xlinker", "-rpath", "-Xlinker", api, + ], + module_name: Self.moduleName(target.name), + srcs: Starlark.glob(["\(prefix)/**/*.swift"]), + tags: Self.manual, + visibility: .public)) + } + + /// `bazel run //:plugins`, and everything it needs built first. + /// + /// The plugins and the tools they run are `data` of the script, so running + /// it builds them: a script that called `bazel build` itself would be a + /// second Bazel inside the first one's lock. + func writePluginRunner(locals: [Path]) throws { + /// The root `BUILD` declares `//:plugins` for any project with packages, + /// because whether one of them has a plugin is not known when that file + /// is written. So both of the things it names are written for any such + /// project: a package with nothing to run is a command that does + /// nothing, and a label that does not resolve is a workspace that does + /// not load. + guard (output + "Package.swift").exists else { return } + + let binaries = pluginBinaries + + try packagesRoot.mkpath() + let group = CodeBuilder() + group.call( + Rules.Builtin.Call.filegroup( + name: "plugins", + srcs: .build { binaries.map(\.label).sorted().map { Starlark.Label.named($0) } }, + visibility: .public)) + try (packagesRoot + "BUILD").write(group.build()) + + let arguments = ["--output", "."] + + locals.flatMap { local in ["--local", local.absolute().string.quoted] } + + binaries.flatMap { binary in + [binary.isPlugin ? "--plugin" : "--tool", "\(binary.name)=$runfiles/\(binary.path)"] + } + + let script = output + "plugins.sh" + try script.write(""" + #!/bin/bash + # Runs this workspace's build tool plugins, writing what they generate + # back into `Packages/*/Generated/*Plugin`. + # + # The plugins and their tools are built by Bazel: they are `data` of this + # script, so they are in its runfiles by the time it runs. + set -euo pipefail + runfiles="${RUNFILES_DIR:-$0.runfiles}/_main" + cd "${BUILD_WORKSPACE_DIRECTORY:-$(dirname "$0")}" + exec bazelize plugins \(arguments.joined(separator: " ")) + + """) + + /// `sh_binary` refuses a script that is not executable. + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.string) + } + + /// What a plugin needs built: the plugin itself, and the tools it runs. + private var pluginBinaries: [PluginBinary] { + var binaries: [PluginBinary] = [] + + for package in workspace.packages where package.isRoot || package.isLocal { + let used = Set(package.manifest.targets.flatMap(\.pluginUsages).map(\.name)) + guard !used.isEmpty else { continue } + + for target in package.manifest.targets where used.contains(target.name) { + guard target.type == "plugin" else { continue } + binaries.append(binary(of: target, in: package, isPlugin: true)) + + for dependency in target.dependencies { + guard case .target(let name) = dependency.kind else { + guard case .byName(let name) = dependency.kind else { continue } + if let tool = tool(named: name, in: package) { binaries.append(tool) } + continue + } + if let tool = tool(named: name, in: package) { binaries.append(tool) } + } + } + } + + return binaries + } + + private func tool(named name: String, in package: SwiftPM.Package) -> PluginBinary? { + guard + let target = package.manifest.targets.first(where: { + $0.name == name && $0.type == "executable" + }) + else { + return nil + } + + return binary(of: target, in: package, isPlugin: false) + } + + private func binary( + of target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + isPlugin: Bool) -> PluginBinary + { + let rule = ruleName(of: target.name, in: package) + let directory = "\(PluginSwiftPM.packagesDirectory)/\(package.directory)" + + return .init( + name: target.name, + label: "//\(directory):\(rule)", + path: "\(directory)/\(rule)", + isPlugin: isPlugin) + } +} + +extension SwiftPM.Generator { + /// A program `//:plugins` has Bazel build before it runs. + struct PluginBinary { + let name: String + let label: String + /// Where it sits in the runner's runfiles. + let path: String + let isPlugin: Bool + } +} + diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginWire.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginWire.swift new file mode 100644 index 0000000..d43264c --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+PluginWire.swift @@ -0,0 +1,373 @@ +// +// SwiftPM+PluginWire.swift +// +// +// The messages a build tool plugin and its host exchange. +// + +import Foundation + +// MARK: - SwiftPM.PluginWire + +extension SwiftPM { + /// What the host sends a plugin and what it says back. + /// + /// A plugin is a program that speaks one protocol: length-prefixed JSON over + /// its standard input and output, carrying enums SwiftPM declares in + /// `PluginMessages.swift`. Only the part a build tool plugin uses is + /// mirrored here — the package graph it is given, and the commands it + /// answers with. + /// + /// The protocol belongs to the toolchain, so a message that cannot be + /// decoded is reported as exactly that rather than read as an absent + /// command. + enum PluginWire { + /// A path, as the wire spells it: a subpath of another path, so a graph + /// of files repeats no directory. + struct URLNode: Encodable { + let baseURLId: Int? + let subpath: String + } + + struct Tool: Encodable { + let path: Int + let triples: [String]? + } + + struct File: Encodable { + let basePathId: Int + let name: String + let type: String + } + + struct Target: Encodable { + let name: String + let directoryId: Int + let dependencies: [Dependency] + let info: TargetInfo + + enum Dependency: Encodable { + case target(Int) + case product(Int) + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: AnyKey.self) + switch self { + case .target(let id): + try container.encode(["targetId": id], forKey: AnyKey("target")) + case .product(let id): + try container.encode(["productId": id], forKey: AnyKey("product")) + } + } + } + } + + /// What the plugin is told a target is made of. + /// + /// Only the kinds a package can hold are spelled out; the shape of each + /// is SwiftPM's, down to the key names. + enum TargetInfo: Encodable { + case swift(module: String, kind: String, sources: [File]) + case clang(module: String, kind: String, sources: [File], publicHeadersDirId: Int?) + case binary(artifactId: Int) + case system + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: AnyKey.self) + switch self { + case .swift(let module, let kind, let sources): + try container.encode( + SwiftInfo( + moduleName: module, + kind: kind, + sourceFiles: sources, + compilationConditions: [], + linkedLibraries: [], + linkedFrameworks: []), + forKey: AnyKey("swiftSourceModuleInfo")) + case .clang(let module, let kind, let sources, let headers): + try container.encode( + ClangInfo( + moduleName: module, + kind: kind, + sourceFiles: sources, + preprocessorDefinitions: [], + headerSearchPaths: [], + publicHeadersDirId: headers, + linkedLibraries: [], + linkedFrameworks: []), + forKey: AnyKey("clangSourceModuleInfo")) + case .binary(let artifact): + try container.encode( + BinaryInfo( + kind: ["xcframework": Empty()], + origin: ["local": Empty()], + artifactId: artifact), + forKey: AnyKey("binaryArtifactInfo")) + case .system: + try container.encode( + SystemInfo(pkgConfig: nil, compilerFlags: [], linkerFlags: []), + forKey: AnyKey("systemLibraryInfo")) + } + } + + private struct SwiftInfo: Encodable { + let moduleName: String + let kind: String + let sourceFiles: [File] + let compilationConditions: [String] + let linkedLibraries: [String] + let linkedFrameworks: [String] + } + + private struct ClangInfo: Encodable { + let moduleName: String + let kind: String + let sourceFiles: [File] + let preprocessorDefinitions: [String] + let headerSearchPaths: [String] + let publicHeadersDirId: Int? + let linkedLibraries: [String] + let linkedFrameworks: [String] + } + + private struct BinaryInfo: Encodable { + let kind: [String: Empty] + let origin: [String: Empty] + let artifactId: Int + } + + private struct SystemInfo: Encodable { + let pkgConfig: String? + let compilerFlags: [String] + let linkerFlags: [String] + } + + private struct Empty: Encodable {} + } + + struct Product: Encodable { + let name: String + let targetIds: [Int] + let info: Info + + enum Info: Encodable { + case executable(mainTargetId: Int) + case library + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: AnyKey.self) + switch self { + case .executable(let main): + try container.encode(["mainTargetId": main], forKey: AnyKey("executable")) + case .library: + try container.encode( + ["kind": ["automatic": [String: String]()]], + forKey: AnyKey("library")) + } + } + } + } + + struct Package: Encodable { + let identity: String + let displayName: String + let directoryId: Int + let origin: Origin + let toolsVersion: ToolsVersion + let dependencies: [Dependency] + let productIds: [Int] + let targetIds: [Int] + + struct ToolsVersion: Encodable { + let major: Int + let minor: Int + let patch: Int + } + + struct Dependency: Encodable { + let packageId: Int + } + + /// Where the package came from. A plugin can ask, and one that does + /// is told the truth: the package under the tool is the root, one in + /// the project's own repository is local, the rest are checkouts. + enum Origin: Encodable { + case root + case local(pathId: Int) + case repository(url: String, displayVersion: String, revision: String) + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: AnyKey.self) + switch self { + case .root: + try container.encode([String: String](), forKey: AnyKey("root")) + case .local(let path): + try container.encode(["path": path], forKey: AnyKey("local")) + case .repository(let url, let version, let revision): + try container.encode( + [ + "url": url, + "displayVersion": version, + "scmRevision": revision, + ], + forKey: AnyKey("repository")) + } + } + } + } + + /// The whole graph, as one message's worth of it. + struct InputContext: Encodable { + let paths: [URLNode] + let targets: [Target] + let products: [Product] + let packages: [Package] + let xcodeTargets: [String] + let xcodeProjects: [String] + let pluginWorkDirId: Int + let toolSearchDirIds: [Int] + let accessibleTools: [String: Tool] + } + + /// `createBuildToolCommands`, the only thing the host asks of a build + /// tool plugin. + struct Request: Encodable { + let context: InputContext + let rootPackageId: Int + let targetId: Int + let pluginGeneratedSources: [Int] + let pluginGeneratedResources: [Int] + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: AnyKey.self) + try container.encode(Body(self), forKey: AnyKey("createBuildToolCommands")) + } + + private struct Body: Encodable { + let context: InputContext + let rootPackageId: Int + let targetId: Int + let pluginGeneratedSources: [Int] + let pluginGeneratedResources: [Int] + + init(_ request: Request) { + context = request.context + rootPackageId = request.rootPackageId + targetId = request.targetId + pluginGeneratedSources = request.pluginGeneratedSources + pluginGeneratedResources = request.pluginGeneratedResources + } + } + } + + /// What a plugin says back. A build tool plugin sends commands and + /// diagnostics; the rest belongs to a command plugin asking the host to + /// build or test something, which this host does not do. + enum Response: Decodable { + case diagnostic(severity: String, message: String) + case progress(String) + case build(Command) + case prebuild(Command, outputDirectory: String) + case unsupported(String) + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: AnyKey.self) + guard let key = container.allKeys.first else { + throw PluginError.undecodable("a message with no case") + } + + switch key.stringValue { + case "emitDiagnostic": + let body = try container.decode(Diagnostic.self, forKey: key) + self = .diagnostic(severity: body.severity, message: body.message) + case "emitProgress": + let body = try container.decode(Progress.self, forKey: key) + self = .progress(body.message) + case "defineBuildCommand": + let body = try container.decode(BuildCommand.self, forKey: key) + self = .build(.init(body.configuration, inputs: body.inputFiles, outputs: body.outputFiles)) + case "definePrebuildCommand": + let body = try container.decode(PrebuildCommand.self, forKey: key) + self = .prebuild( + .init(body.configuration, inputs: [], outputs: []), + outputDirectory: body.outputFilesDirectory) + default: + self = .unsupported(key.stringValue) + } + } + + private struct Diagnostic: Decodable { + let severity: String + let message: String + } + + private struct Progress: Decodable { + let message: String + } + + private struct BuildCommand: Decodable { + let configuration: Command.Configuration + let inputFiles: [String] + let outputFiles: [String] + } + + private struct PrebuildCommand: Decodable { + let configuration: Command.Configuration + let outputFilesDirectory: String + } + } + + /// A program the plugin asks to have run, with what it says it reads and + /// writes. + struct Command { + let displayName: String? + let executable: String + let arguments: [String] + let environment: [String: String] + let workingDirectory: String? + let inputs: [String] + let outputs: [String] + + init(_ configuration: Configuration, inputs: [String], outputs: [String]) { + displayName = configuration.displayName + executable = configuration.executable + arguments = configuration.arguments + environment = configuration.environment + workingDirectory = configuration.workingDirectory + self.inputs = inputs + self.outputs = outputs + } + + struct Configuration: Decodable { + let displayName: String? + let executable: String + let arguments: [String] + let environment: [String: String] + let workingDirectory: String? + } + } + } +} + +// MARK: - SwiftPM.PluginError + +extension SwiftPM { + enum PluginError: Error, CustomStringConvertible { + /// The toolchain's protocol is not the one mirrored here. + case undecodable(String) + case compileFailed(String) + case noToolchain + + var description: String { + switch self { + case .undecodable(let reason): + return "the plugin protocol of this toolchain is not the one bazelize speaks: \(reason)" + case .compileFailed(let reason): + return "the plugin itself does not compile: \(reason)" + case .noToolchain: + return "no toolchain to compile a plugin with" + } + } + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift new file mode 100644 index 0000000..ee1abb4 --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Resources.swift @@ -0,0 +1,306 @@ +// +// SwiftPM+Resources.swift +// +// +// A package target's resources, as a bundle plus the accessor that finds it. +// + +import BazelRules +import Foundation +@preconcurrency import PathKit +import Starlark +import Util + +extension SwiftPM.Generator { + /// What a target's resources add to its own rule. + struct ResourceBundle { + /// The rule the library carries as `data`. + let label: String + /// Generated sources compiled into the library: the accessor a package's + /// own code calls to reach its bundle. + let accessors: [String] + /// The header a C-family target force-includes, so `SWIFTPM_MODULE_BUNDLE` + /// resolves without the sources importing anything. + let header: String? + } + + /// The resources of one target, or `nil` when it has none. + /// + /// SwiftPM puts a target's resources in a bundle named `_` + /// and compiles an accessor that finds it at runtime; a package reaches its + /// own resources only through that pair, so both are generated here. + /// + /// `generated` are the files a build tool plugin produced that the target + /// does not compile. SwiftPM bundles those the same way, so a target whose + /// only resources come from a plugin still gets a bundle. + func buildResources( + _ target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + prefix: String, + root: Path, + kind: TargetKind, + generated: [String], + builder: CodeBuilder) throws -> ResourceBundle? + { + guard let directory = sourceDirectory(of: target, in: package) else { return nil } + + let files = relativeFiles(of: target, in: package, prefix: prefix) + + /// `.copy` keeps the item's own name and inner structure and nothing above + /// it, which is a structured resource with the path above the item stripped; + /// `.process` lets the bundler place each file. + var resources: [String] = [] + var copied: [String: [String]] = [:] + for resource in target.resources { + let pattern = Self.pattern(of: resource.path, in: directory, prefix: prefix) + if resource.isCopy { + let above = Path("\(prefix)/\(resource.path)").parent().normalize().string + copied[above, default: []].append(pattern) + } else { + resources.append(pattern) + } + } + resources = matching(resources, files) + + matching(Self.discoveredResources(prefix: prefix), files) + /// A plugin's output is named as it was found on disk, so it needs no + /// matching against the target's own files. + + generated + let structured = copied + .mapValues { matching($0, files) } + .filter { !$0.value.isEmpty } + + /// A shader compiles like any other source: it includes the target's + /// headers, so they belong to the same resource group. The bundler treats a + /// bundled header as a Metal header and compiles it into the library + /// instead of copying it. + if resources.contains(where: { $0.hasSuffix(".metal") }) { + resources += matching( + SwiftPM.Generator.headerExtensions.map { "\(prefix)/**/*.\($0)" }, + relativeFiles(of: target, in: package, prefix: prefix, excluding: false)) + } + + /// A declared resource that is not on disk leaves nothing to bundle, and a + /// bundle rule without resources is an empty bundle. + guard !resources.isEmpty || !structured.isEmpty else { return nil } + + let bundle = "\(package.manifest.name)_\(target.name)" + let name = "\(ruleName(of: target.name, in: package))Resources" + try (root + "Generated").mkpath() + + let plist = "Generated/\(target.name)ResourceBundle-Info.plist" + try (root + plist).write(Self.infoPlist(bundle: bundle)) + + /// One group per directory a copied item sits in: the group is what can say + /// how much of the path to drop, so the item lands at the bundle's root the + /// way SwiftPM copies it. + var groups: [String] = [] + + /// Processed resources join the groups when there is one, so the bundle's + /// attribute stays one kind of thing. + if !structured.isEmpty, let patterns = resources.nonEmpty { + let group = "\(name)Processed" + groups.append(group) + + builder.load(loadableRule: Rules.Apple.Resources.apple_resource_group) + builder.call( + Rules.Apple.Resources.Call.apple_resource_group( + name: group, + resources: Starlark.glob(patterns, allowEmpty: true))) + } + + for (index, prefixToStrip) in structured.keys.sorted().enumerated() { + guard let patterns = structured[prefixToStrip] else { continue } + + let group = "\(name)Copied\(index)" + groups.append(group) + + builder.load(loadableRule: Rules.Apple.Resources.apple_resource_group) + builder.call( + Rules.Apple.Resources.Call.apple_resource_group( + name: group, + strip_structured_resources_prefixes: [prefixToStrip], + structured_resources: Starlark.glob(patterns))) + } + + builder.load(loadableRule: Rules.Apple.Resources.apple_resource_bundle) + builder.call( + Rules.Apple.Resources.Call.apple_resource_bundle( + name: name, + bundle_name: bundle, + infoplists: .build { [Starlark.Label.named(plist)] }, + /// A glob and a group cannot be added together in one attribute, so + /// once there is a group everything is a group. + resources: groups.isEmpty + ? resources.nonEmpty.map { Starlark.glob($0, allowEmpty: true) } + : .build { groups.map { Starlark.Label.named(":\($0)") } }, + tags: Self.manual)) + + switch kind { + case .swift, .executable, .test: + let accessor = "Generated/\(target.name)ResourceBundleAccessor.swift" + try (root + accessor).write(Self.swiftAccessor(bundle: bundle)) + return ResourceBundle(label: ":\(name)", accessors: [accessor], header: nil) + case .clang: + let module = Self.moduleName(target.name) + let header = "Generated/\(target.name)ResourceBundleAccessor.h" + let implementation = "Generated/\(target.name)ResourceBundleAccessor.m" + try (root + header).write(Self.objcAccessorHeader(module: module)) + try (root + implementation).write( + Self.objcAccessor(module: module, bundle: bundle)) + return ResourceBundle( + label: ":\(name)", + accessors: [header, implementation], + header: header) + case .binary, .system, .macro, .unsupported: + return nil + } + } + + /// The resource types SwiftPM treats as resources without being told, so a + /// package that ships a xib and declares nothing still gets a bundle. + private static func discoveredResources(prefix: String) -> [String] { + discoveredExtensions.map { "\(prefix)/**/*.\($0)" } + /// A catalog or a model is a directory, so what a glob can name is the + /// files inside it — as is a `.lproj` directory, which makes every file + /// in it a localized resource whatever its own type is. + + (discoveredDirectoryExtensions + ["lproj"]).map { "\(prefix)/**/*.\($0)/**" } + } + + /// The file types SwiftPM turns into resources on its own, from its own file + /// rules. + private static let discoveredExtensions = [ + "nib", + "xib", + "storyboard", + "xcstrings", + "metal", + ] + + private static let discoveredDirectoryExtensions = [ + "xcassets", + "xcdatamodel", + "xcdatamodeld", + "xcmappingmodel", + ] + + /// A resource path is a file or a directory; a directory contributes + /// everything under it. + private static func pattern(of path: String, in directory: Path, prefix: String) -> String { + (directory + path).isDirectory + ? "\(prefix)/\(path)/**" + : "\(prefix)/\(path)" + } + + /// The bundle SwiftPM produces carries an `Info.plist`; without one the bundle + /// is not loadable. + private static func infoPlist(bundle: String) -> String { + """ + + + + + CFBundleIdentifier + org.swift.\(bundle) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + \(bundle) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + + + """ + } + + /// `Bundle.module`, the name a package's Swift code uses. + /// + /// The bundle sits next to the binary that linked the package, and which + /// binary that is depends on whether the package went into an app, a + /// framework or a tool — so every candidate is tried. + private static func swiftAccessor(bundle: String) -> String { + """ + import Foundation + + private final class BundleFinder {} + + extension Foundation.Bundle { + static let module: Bundle = { + let candidates = [ + Bundle.main.resourceURL, + Bundle(for: BundleFinder.self).resourceURL, + Bundle.main.bundleURL, + ] + + for candidate in candidates { + let url = candidate?.appendingPathComponent("\(bundle).bundle") + if let bundle = url.flatMap(Bundle.init(url:)) { + return bundle + } + } + + fatalError("unable to find bundle named \(bundle)") + }() + } + + """ + } + + /// The C-family half of the same accessor. SwiftPM force-includes this header + /// into every source of the target, which is how `SWIFTPM_MODULE_BUNDLE` + /// appears without an import. + private static func objcAccessorHeader(module: String) -> String { + """ + #ifdef __OBJC__ + #import + + #if __cplusplus + extern "C" { + #endif + + NSBundle *\(module)_SWIFTPM_MODULE_BUNDLE(void); + + #define SWIFTPM_MODULE_BUNDLE \(module)_SWIFTPM_MODULE_BUNDLE() + + #if __cplusplus + } + #endif + #endif + + """ + } + + private static func objcAccessor(module: String, bundle: String) -> String { + """ + #import + + @interface \(module)_BundleFinder : NSObject + @end + + @implementation \(module)_BundleFinder + @end + + NSBundle *\(module)_SWIFTPM_MODULE_BUNDLE(void) { + NSArray *candidates = @[ + [[NSBundle mainBundle] bundleURL], + [[NSBundle bundleForClass:[\(module)_BundleFinder class]] bundleURL], + ]; + + for (NSURL *base in candidates) { + NSURL *url = [base URLByAppendingPathComponent:@"\(bundle).bundle"]; + NSBundle *found = [NSBundle bundleWithURL:url]; + if (found != nil) { + return found; + } + } + + return nil; + } + + """ + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Settings.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Settings.swift new file mode 100644 index 0000000..4fbe97f --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Settings.swift @@ -0,0 +1,95 @@ +// +// SwiftPM+Settings.swift +// +// +// `SwiftSetting` and `LinkerSetting` as compiler and linker flags. +// + +import Foundation + +extension SwiftPM.Generator { + /// What the target compiles with, beyond the defaults. + /// + /// SwiftPM hands these to `swiftc` directly, so they are copts rather than + /// anything the rules model: a `defines` attribute would re-tokenize a value + /// and a feature is not a flag the rules know. + func copts(of target: SwiftPM.PackageTarget) -> [String] { + swiftDefines(of: target) + target.settings.flatMap { setting -> [String] in + guard setting.tool == "swift", let name = setting.name else { return [] } + + switch name { + case "swiftLanguageMode", "swiftLanguageVersion": + guard let version = setting.values.first else { return [] } + return ["-swift-version", version] + case "defaultIsolation": + guard let isolation = setting.values.first else { return [] } + return ["-default-isolation", isolation] + case "enableUpcomingFeature": + return setting.values.flatMap { feature in + ["-enable-upcoming-feature", feature] + } + case "enableExperimentalFeature": + return setting.values.flatMap { feature in + ["-enable-experimental-feature", feature] + } + case "strictMemorySafety": + return ["-strict-memory-safety"] + case "interoperabilityMode": + guard let mode = setting.values.first else { return [] } + return ["-cxx-interoperability-mode=\(mode)"] + case "unsafeFlags": + return setting.values + default: + return [] + } + } + } + + /// `SWIFT_PACKAGE` is what a package's own sources test for; SwiftPM defines it + /// for every target it builds. + /// + /// These are flags, not the `defines` attribute: that attribute propagates to + /// everything that depends on the library, and a project's own target must not + /// compile as if it were a package — Xcode's generated asset symbols, for one, + /// switch on `SWIFT_PACKAGE`. + func swiftDefines(of target: SwiftPM.PackageTarget) -> [String] { + let declared = target.settings.compactMap { setting -> [String]? in + guard setting.tool == "swift", setting.name == "define" else { return nil } + return setting.values + }.flatMap { $0 } + + return (["SWIFT_PACKAGE"] + declared).flatMap { define in + ["-D\(define)", "-Xcc", "-D\(define)"] + } + } + + /// A package can name a system library or framework it needs; nothing else in + /// the graph knows about it. + func linkopts(of target: SwiftPM.PackageTarget) -> [String] { + target.settings.flatMap { setting -> [String] in + guard setting.tool == "linker", let name = setting.name else { return [] } + + switch name { + case "linkedLibrary": + return setting.values.map { library in + "-l\(library)" + } + case "linkedFramework": + return setting.values.flatMap { framework in + ["-framework", framework] + } + case "unsafeFlags": + return setting.values + default: + return [] + } + } + } +} + +extension Array { + /// `nil` rather than an empty attribute. + var nonEmpty: [Element]? { + isEmpty ? nil : self + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+SystemLibrary.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+SystemLibrary.swift new file mode 100644 index 0000000..62e148f --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+SystemLibrary.swift @@ -0,0 +1,83 @@ +// +// SwiftPM+SystemLibrary.swift +// +// +// Rules for a package target that wraps a library the system already has. +// + +import BazelRules +import Foundation +@preconcurrency import PathKit +import Starlark +import Util + +extension SwiftPM.Generator { + /// A system-library target is a module map over headers that are already on + /// the machine, so the rule compiles nothing and only says what to link. + /// + /// The module map is the whole interface: it names the headers and, through + /// its `link` directives, the libraries and frameworks the module needs. + func buildSystemLibrary( + _ target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + prefix: String, + root: Path, + builder: CodeBuilder) -> Bool + { + let moduleMap = "\(prefix)/module.modulemap" + guard (root + moduleMap).exists else { + Log.codeGenerate.warning(""" + Skip \(package.directory, privacy: .public)/\(target.name, privacy: .public): \ + a system library target without a module map + """) + return false + } + + let name = ruleName(of: target.name, in: package) + let hint = "\(name)_interop" + let files = relativeFiles(of: target, in: package, prefix: prefix) + + builder.load(loadableRule: Rules.Swift.swift_interop_hint) + builder.call( + Rules.Swift.Call.swift_interop_hint( + name: hint, + module_map: .named(moduleMap), + module_name: Self.moduleName(target.name))) + + builder.load(loadableRule: Rules.Cc.cc_library) + builder.call( + Rules.Cc.Call.cc_library( + name: name, + aspect_hints: .build { [Starlark.Label.named(":\(hint)")] }, + hdrs: matching(Self.headerExtensions.map { "\(prefix)/**/*.\($0)" }, files) + .nonEmpty + .map { Starlark.glob($0) }, + includes: [prefix], + linkopts: Self.linkopts(moduleMap: root + moduleMap).nonEmpty, + tags: Self.manual, + visibility: .public)) + + return true + } + + /// `link "z"` and `link framework "Cocoa"` in a module map are what the module + /// needs at link time; nothing else in the manifest says so. + static func linkopts(moduleMap: Path) -> [String] { + guard let content: String = try? moduleMap.read() else { return [] } + + var linkopts: [String] = [] + for line in content.split(separator: "\n") { + let statement = line.trimmingCharacters(in: .whitespaces) + guard statement.hasPrefix("link ") else { continue } + + guard let name = statement.split(separator: "\"").dropFirst().first else { continue } + if statement.hasPrefix("link framework") { + linkopts.append(contentsOf: ["-framework", String(name)]) + } else { + linkopts.append("-l\(name)") + } + } + + return linkopts + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Test.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Test.swift new file mode 100644 index 0000000..92802ba --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Test.swift @@ -0,0 +1,74 @@ +// +// SwiftPM+Test.swift +// +// +// Rules for the tests of the package that was handed to bazelize. +// + +import BazelRules +import Foundation +@preconcurrency import PathKit +import Starlark +import Util + +extension SwiftPM.Generator { + /// A test target of the package handed in becomes a test bundle over a library + /// of its sources. + /// + /// The tests of a package a project merely depends on are not generated: + /// running them says nothing about the project, and they pull in dependencies + /// nothing else needs. The tests of the package under the tool are the whole + /// point of pointing the tool at it. + /// + /// It is a bundle rather than a plain `swift_test` because a test target has + /// resources like any other, and only a bundling rule puts them where + /// `Bundle.module` looks. + func buildTest( + _ target: SwiftPM.PackageTarget, + in package: SwiftPM.Package, + prefix: String, + generated: [String], + resources: ResourceBundle?, + builder: CodeBuilder) + { + let name = ruleName(of: target.name, in: package) + let library = "\(name)_library" + + builder.load(loadableRule: Rules.Swift.swift_library) + builder.call( + Rules.Swift.Call.swift_library( + name: library, + always_include_developer_search_paths: true, + copts: copts(of: target).nonEmpty, + module_name: Self.moduleName(target.name), + package_name: package.manifest.name, + srcs: Starlark.glob( + matching( + sources(of: target, prefix: prefix, extensions: ["swift"]), + relativeFiles(of: target, in: package, prefix: prefix)) + + generated + + (resources?.accessors ?? []), + exclude: excluded(target, prefix: prefix), + allowEmpty: true), + deps: deps(of: target, in: package).nonEmpty.map { labels in + .build { labels } + }, + data: resources.map { bundle in + .build { [Starlark.Label.named(bundle.label)] } + }, + linkopts: linkopts(of: target).nonEmpty, + tags: Self.manual, + testonly: true, + visibility: .private)) + + builder.load(.macos_unit_test) + builder.call( + Rules.Apple.MacOS.Call.macos_unit_test( + name: name, + deps: .build { [Starlark.Label.named(":\(library)")] }, + /// A package's tests run where the tool runs, so the version is the + /// one the package asks of macOS. + minimum_os_version: deployment.required(package, platform: "macos"), + visibility: .public)) + } +} diff --git a/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift b/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift new file mode 100644 index 0000000..9994d1c --- /dev/null +++ b/Sources/BazelizeKit/SwiftPM/SwiftPM+Workspace.swift @@ -0,0 +1,183 @@ +// +// SwiftPM+Workspace.swift +// +// +// Resolving the packages a project depends on and reading their manifests. +// + +import Foundation +@preconcurrency import PathKit +import Subprocess +import System +import Util + +extension SwiftPM { + /// A package as the generator sees it: where its sources are, what it declares, + /// and the directory its products are exposed under. + struct Package { + /// The directory under `Packages/`, named the way a human refers to the + /// package. + let directory: String + /// The checkout the sources come from. + let root: Path + let manifest: Manifest + /// `true` for a package in the project's own repository. + let isLocal: Bool + /// `true` for the package that was handed to bazelize, as opposed to one + /// something else depends on. + let isRoot: Bool + + /// The name SwiftPM files the package's artifacts under. + var identity: String { + directory.lowercased() + } + } + + /// Everything the generator needs about one project's package graph. + struct Workspace { + let packages: [Package] + + /// Where SwiftPM unpacked the binary targets it fetched. + let artifacts: Path + + /// Which directory a package identity or manifest name resolves to, so a + /// product dependency can be turned into a label. + let directoryByIdentity: [String: String] + } +} + +extension SwiftPM { + /// Resolves the workspace `Package.swift` and reads every checkout's manifest. + /// + /// SwiftPM owns resolution: it already wrote `Package.resolved`, and its + /// checkouts are the sources the rules will point at. `dump-package` is read + /// per checkout because it is the manifest SwiftPM itself evaluated — cheap, + /// offline, and it spans every tools version in the graph. + /// + /// `locals` are the packages of the project's own repository, the same ones the + /// generated manifest declares as `path:` dependencies. They are handed in + /// rather than read back out of that manifest: the caller that wrote it knows + /// them. + static func loadWorkspace(output: Path, root input: Path?, locals: [Path]) async throws -> Workspace { + /// A project with no packages has no manifest written for it, and asking + /// SwiftPM to resolve one is an error rather than an empty graph. + guard (output + "Package.swift").exists else { + return .init( + packages: [], + artifacts: output + ".build/artifacts", + directoryByIdentity: [:]) + } + + try await resolve(output: output) + + let checkouts = output + ".build/checkouts" + var packages: [Package] = [] + var directoryByIdentity: [String: String] = [:] + + for root in try roots(checkouts: checkouts, locals: locals) { + guard let manifest = try await manifest(at: root.path) else { continue } + + let package = Package( + directory: root.directory, + root: root.path, + manifest: manifest, + isLocal: root.isLocal, + /// Both sides are made absolute: the output can be a relative path, + /// and the package handed in is named however the caller named it. + isRoot: input.map { $0.absolute().normalize() == root.path.absolute().normalize() } ?? false) + packages.append(package) + + for identity in [manifest.name, root.directory, root.path.lastComponent] { + directoryByIdentity[identity.lowercased()] = root.directory + } + } + + return .init( + packages: packages, + artifacts: output + ".build/artifacts", + directoryByIdentity: directoryByIdentity) + } + + // MARK: Private + + private struct Root { + let directory: String + let path: Path + let isLocal: Bool + } + + /// `swift package resolve` fetches what `Package.resolved` pins; without it + /// there are no checkouts to read. + private static func resolve(output: Path) async throws { + Log.codeGenerate.info("swift package resolve at \(output.string, privacy: .public)") + + let result = try await Subprocess.run( + .name("swift"), + arguments: Arguments(["package", "resolve"]), + workingDirectory: FilePath(output.string), + output: .discarded, + error: .currentStandardError) + + guard result.terminationStatus.isSuccess else { + throw SwiftPMError.resolveFailed(status: "\(result.terminationStatus)") + } + } + + /// Remote packages live in `.build/checkouts`; a local one is wherever its + /// manifest is, and is read in place. + private static func roots(checkouts: Path, locals: [Path]) throws -> [Root] { + var roots: [Root] = [] + + if checkouts.exists { + for child in try checkouts.children() where child.isDirectory { + roots.append(.init(directory: child.lastComponent, path: child, isLocal: false)) + } + } + + for path in locals { + let root = path.absolute().normalize() + guard root.exists else { continue } + roots.append(.init(directory: root.lastComponent, path: root, isLocal: true)) + } + + return roots.sorted { $0.directory < $1.directory } + } + + private static func manifest(at root: Path) async throws -> Manifest? { + let result = try await Subprocess.run( + .name("swift"), + arguments: Arguments(["package", "dump-package", "--package-path", root.string]), + output: .data(limit: 32 * 1024 * 1024), + error: .discarded) + + guard result.terminationStatus.isSuccess else { + Log.codeGenerate.warning(""" + No manifest for \(root.lastComponent, privacy: .public): dump-package failed + """) + return nil + } + + do { + return try JSONDecoder().decode(Manifest.self, from: Data(result.standardOutput)) + } catch { + Log.codeGenerate.warning(""" + Cannot read the manifest of \(root.lastComponent, privacy: .public): \ + \(error.localizedDescription, privacy: .public) + """) + return nil + } + } +} + +// MARK: - SwiftPMError + +enum SwiftPMError: Error, CustomStringConvertible { + case resolveFailed(status: String) + + var description: String { + switch self { + case .resolveFailed(let status): + return "swift package resolve failed: \(status)" + } + } +} diff --git a/Sources/BazelizeKit/XcodeCompat.swift b/Sources/BazelizeKit/XcodeCompat.swift new file mode 100644 index 0000000..0b800e7 --- /dev/null +++ b/Sources/BazelizeKit/XcodeCompat.swift @@ -0,0 +1,221 @@ +import Foundation +import PathKit +import Starlark + +typealias Project = Xcode.Project +typealias Target = Xcode.Target +typealias BuildSettings = Xcode.BuildSettings +typealias File = Xcode.File +typealias RemotePackage = Xcode.RemotePackage +typealias LocalPackage = Xcode.LocalPackage +typealias PackageProductDependency = Xcode.PackageProductDependency +typealias DeviceFamily = Xcode.DeviceFamily + +extension Dictionary where Key == String, Value == BuildSettings { + func select(_ keypath: KeyPath) -> Starlark.Select { + let values = map { _, setting in + setting[keyPath: keypath] + } + + if Set(values).count == 1, let first = first?.value[keyPath: keypath] { + return .same(first) + } + + let result: [Starlark.Label: T] = reduce(into: [:]) { partialResult, entry in + partialResult[.config(entry.key)] = entry.value[keyPath: keypath] + } + return .various(result) + } +} + +extension Project { + fileprivate func target(named name: String) -> Target? { + targets.first { $0.name == name } + } +} + +extension Target { + func select(_ keyPath: KeyPath, project _: Project) -> Starlark.Select { + configs.select(keyPath) + } + + fileprivate func isExtensionTarget(_ name: String, in project: Project) -> Bool { + guard let productType = project.target(named: name)?.productType else { return false } + return productType.contains("app-extension") + } + + /// A product that carries its own entry point or is a standalone bundle cannot be + /// linked into another target: Xcode embeds it instead, and linking it would + /// duplicate `main`. + fileprivate func isLinkableTarget(_ name: String, in project: Project) -> Bool { + guard let productType = project.target(named: name)?.productType else { return true } + + /// A test bundle is the exception: Xcode loads it into the host process, so + /// the host's code has to be reachable. Bazel has no `-bundle_loader` + /// equivalent for a logic test, so the host is linked in. + if isTest, productType == "com.apple.product-type.application" { + return true + } + + switch productType { + case "com.apple.product-type.application", + "com.apple.product-type.tool", + "com.apple.product-type.bundle.unit-test", + "com.apple.product-type.bundle.ui-testing": + return false + default: + return !productType.contains("app-extension") + } + } + + fileprivate func linkedTargetDependencyNames(project: Project) -> [String] { + dependencies.targets.filter { target in + isLinkableTarget(target, in: project) && project.target(named: target)?.hasSources != false + } + } + + func embeddedExtensionTargetNames(project: Project) -> [String] { + dependencies.targets.filter { isExtensionTarget($0, in: project) } + } + + var frameworksLibrary: [Starlark.Label] { + let targetLabels = dependencies.targets + .sorted() + .map { target in + Starlark.Label.named("//Targets/\(target):\(target)_library") + } + let frameworkLabels = dependencies.frameworks + .sorted() + .map(Starlark.Label.named) + return Array(Set(targetLabels + frameworkLabels)).sorted { $0.text < $1.text } + } + + var frameworks: [Starlark.Label] { + let targetLabels = dependencies.targets + .sorted() + .map { target in + Starlark.Label.named("//Targets/\(target):\(target)") + } + let frameworkLabels = dependencies.frameworks + .sorted() + .map(Starlark.Label.named) + return Array(Set(targetLabels + frameworkLabels)).sorted { $0.text < $1.text } + } + + func linkedFrameworksLibrary(project: Project) -> [Starlark.Label] { + /// Every dependency is compiled and linked against as a library; a bundle + /// that embeds one of them passes it in `frameworks` as well, and rules_apple + /// then keeps those symbols out of the embedding binary. + let targetLabels = linkedTargetDependencyNames(project: project) + .sorted() + .map { target in + Starlark.Label.named("//Targets/\(target):\(target)_library") + } + let frameworkLabels = dependencies.frameworks + .sorted() + .map(Starlark.Label.named) + return Array(Set(targetLabels + frameworkLabels)).sorted { $0.text < $1.text } + } + + func linkedFrameworks(project: Project) -> [Starlark.Label] { + let targetLabels = linkedTargetDependencyNames(project: project) + .sorted() + .map { target in + Starlark.Label.named("//Targets/\(target):\(target)") + } + let frameworkLabels = dependencies.frameworks + .sorted() + .map(Starlark.Label.named) + return Array(Set(targetLabels + frameworkLabels)).sorted { $0.text < $1.text } + } + + /// Sibling frameworks Xcode copies into the bundle: the app links against the + /// framework and loads it at runtime, so its resources stay in the framework and + /// `Bundle(for:)` resolves there. Linking its library instead loses both. + func embeddedFrameworkNames(project: Project) -> [String] { + let siblings = Set(project.targets.map(\.name)) + + let names = files.copyFiles.compactMap { file -> String? in + guard file.fileType == "wrapper.framework" else { return nil } + guard let component = file.name ?? file.path else { return nil } + return Path(component).lastComponentWithoutExtension + } + + return Array(Set(names).intersection(siblings)).sorted().filter { name in + project.target(named: name)?.hasSources == true + } + } + + /// The bundle that embeds this target, if any: an embedded bundle inherits the + /// parent's version and identifier prefix, which rules_apple insists on. + /// + /// `nil` when two applications embed it: one bundle cannot carry both prefixes, + /// so the target stays a library linked into each of them, the way it was before + /// it was recognized as embedded at all. + func embeddingBundle(project: Project?) -> Target? { + guard let project else { return nil } + + let parents = project.targets.filter { parent in + parent.name != name + && (parent.embeddedFrameworkNames(project: project).contains(name) + || parent.embeddedExtensionTargetNames(project: project).contains(name)) + } + + let applications = parents.filter { parent in + parent.productType == "com.apple.product-type.application" + } + guard applications.count <= 1 else { return nil } + + /// A framework embedded in both the app and one of its extensions follows + /// the app: that is what rules_apple compares everything to. + return applications.first ?? parents.first + } + + /// `PRODUCT_BUNDLE_IDENTIFIER`, prefixed with the identifier of the bundle that + /// embeds this one. + /// + /// Apple requires the prefix and rules_apple enforces it; Xcode does not, so a + /// framework in the same project routinely carries an unrelated identifier. + func bundleIdentifier(project: Project?) -> String? { + /// rules_apple substitutes nothing here, so a reference Xcode would have + /// expanded — UTM spells every identifier + /// `$(PRODUCT_BUNDLE_PREFIX:default=com.utmapp).X` — is expanded first. + let own = prefer(\.metadata.bundleID) + .map { identifier in + identifier.resolvingBuildSettingReferences(with: selectedSettings, reserved: []) + } + .flatMap { identifier in + identifier.contains("$") ? nil : identifier + } + + guard + let parent = embeddingBundle(project: project), + let parentID = parent.bundleIdentifier(project: project), + let own, !own.hasPrefix("\(parentID).") + else { + return own + } + + let suffix = own.components(separatedBy: ".").last ?? name + return "\(parentID).\(suffix)" + } + + func embeddedFrameworks(project: Project) -> [Starlark.Label] { + embeddedFrameworkNames(project: project) + .filter { target in + project.target(named: target)?.embeddingBundle(project: project)?.name == name + } + .map { target in + Starlark.Label.named("//Targets/\(target):\(target)") + } + } + + func embeddedExtensions(project: Project) -> [Starlark.Label] { + embeddedExtensionTargetNames(project: project) + .filter { project.target(named: $0)?.hasSources == true } + .sorted() + .map { target in + Starlark.Label.named("//Targets/\(target):\(target)") + } + } +} diff --git a/Sources/Cocoapod/CodeGen/NewPod.swift b/Sources/Cocoapod/CodeGen/NewPod.swift deleted file mode 100644 index d5bf6a3..0000000 --- a/Sources/Cocoapod/CodeGen/NewPod.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// NewPodRepository.swift -// -// -// Created by Yume on 2022/5/11. -// - -import Foundation - -// MARK: - NewPodRepository - -/// https://github.com/${organization,user}/${repo}/archive/${commit,branch,tag}.zip -/// -/// new_pod_repository( -/// name = "PINOperation", -/// url = "https://github.com/pinterest/PINOperation/archive/1.2.1.zip", -/// ) -protocol NewPodRepository { - var name: String { get } - var url: String { get } - - var code: String { get } -} - -extension NewPodRepository { - var code: String { - """ - new_pod_repository( - name = "\(name)", - url = "\(url)", - ) - """ - } -} diff --git a/Sources/Cocoapod/Model/PodSpec.swift b/Sources/Cocoapod/Model/PodSpec.swift deleted file mode 100644 index afe6c78..0000000 --- a/Sources/Cocoapod/Model/PodSpec.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// PodSpec.swift -// -// -// Created by Yume on 2022/4/25. -// - -import Foundation -import Util - -// MARK: - PodSpec - -struct PodSpec: Codable, JSONParsable, NewPodRepository { - let name: String - let source: PodSpecSource - - var url: String { - source.url - } -} - -// MARK: - PodSpecSource - -// "source": { -// "git": "https://github.com/yagiz/Bagel.git", -// "tag": "1.4.0" -// }, -struct PodSpecSource: Codable { - let git: String - let tag: String - - /// https://github.com/yagiz/Bagel/archive/1.4.0.zip - var url: String { - let base = git.replacingOccurrences(of: ".git", with: "") - return "\(base)/archive/\(tag).zip" - } -} diff --git a/Sources/Cocoapod/Model/Podfile.swift b/Sources/Cocoapod/Model/Podfile.swift deleted file mode 100644 index 0544be5..0000000 --- a/Sources/Cocoapod/Model/Podfile.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -// Podfile.swift -// -// -// Created by Yume on 2022/4/25. -// - -import AnyCodable -import Foundation -import PathKit -import Util - -// MARK: - Podfile - -struct Podfile: Codable, JSONParsable { - // MARK: Internal - - static func process(_ path: Path) async throws -> Podfile { - /// pod ipc podfile-json Podfile - let data = try await Process.execute( - Env.pod, - arguments: "ipc", "podfile-json", path.string) - - return try Podfile.parse(data) - } - - - subscript(targetName: String) -> [String] { - self[target: targetName]?.depsCode ?? [] - } - - // MARK: Fileprivate - - fileprivate let target_definitions: [PodDefinition] - - fileprivate var flatTarget: [PodChildren] { - target_definitions.flatMap(\.flatTarget) - } - - - fileprivate subscript(target targetName: String) -> PodChildren? { - flatTarget.first { _target in - _target.name == targetName - } - } -} - -// MARK: - PodDefinition - -private struct PodDefinition: Codable { - fileprivate let children: [PodChildren] - fileprivate var flatTarget: [PodChildren] { - children.flatMap(\.flatTarget) - } -} - -// MARK: - PodChildren - -private struct PodChildren: Codable { - // MARK: Internal - - var depsCode: [String] { - dependencies? - .sorted { lhs, rhs in - lhs.code < rhs.code - } - .map(\.code) ?? [] - } - - // MARK: Fileprivate - - /// Target - fileprivate let name: String - fileprivate let dependencies: [PodDependency]? - - - fileprivate var flatTarget: [PodChildren] { - if let child = children { - return [self] + child.flatMap(\.flatTarget) - } else { - return [self] - } - } - - // MARK: Private - -// "configuration_pod_whitelist": { -// "Debug": [ -// "Peek", -// "Bagel" -// ] -// }, -// let configuration_pod_whitelist - private let children: [PodChildren]? -} - -// MARK: - PodDependency - -private struct PodDependency: Codable { - // MARK: Lifecycle - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - do { - let name = try container.decode(String.self) - (package, target) = Util.parse(name: name) - return - } catch { - let podGraph = try container.decode([String: AnyCodable].self) - guard let name = podGraph.keys.first else { - throw PodError.reason(""" - Parse Podfile fail - Pod: \(podGraph) - """) - } - - (package, target) = Util.parse(name: name) - return - } - } - - // MARK: Internal - - let package: String - let target: String - - - /// //Vendor/__PACKAGE__:__TARGET__ - /// - /// subpsec `Core` in `PINCache` - /// //Vendor/PINCache:Core - /// - /// "//Vendor/RxSwift:RxSwift", - var code: String { - """ - //Vendor/\(package):\(target) - """ - } -} diff --git a/Sources/Cocoapod/Model/PodfileLock.swift b/Sources/Cocoapod/Model/PodfileLock.swift deleted file mode 100644 index ed5904c..0000000 --- a/Sources/Cocoapod/Model/PodfileLock.swift +++ /dev/null @@ -1,259 +0,0 @@ -// -// PodfileLock.swift -// -// -// Created by Yume on 2022/4/26. -// - -import Foundation -import Util - -// MARK: - PodfileLock - -struct PodfileLock: Codable, YamlParsable { - // MARK: Internal - - enum CodingKeys: String, CodingKey { - case pods = "PODS" - case externals = "EXTERNAL SOURCES" - case checkouts = "CHECKOUT OPTIONS" - case spec = "SPEC CHECKSUMS" - } - - - var repos: [NewPodRepository] { - get async throws { - try await withThrowingTaskGroup(of: NewPodRepository.self) { group -> [NewPodRepository] in - for spec in specs { - group.addTask { - try await spec.repo(lock: self) - } - } - - return try await group.all - }.sorted { lhs, rhs in - lhs.name < rhs.name - } - } - } - - var repoCodes: [String] { - get async throws { - try await repos.map(\.code) - } - } - - // MARK: Private - - private let pods: [PodfileLock.Pod] - private let externals: [String: ExternalSource]? - private let checkouts: [String: CheckoutOption]? - private let spec: [String: String] - - - private var specs: [PodfileLock.Pod] { - let set = Set(pods) - return set.map { $0 } - } -} - -// MARK: PodfileLock.Pod - -extension PodfileLock { - /// //Vendor/__PACKAGE__:__TARGET__ - fileprivate struct Pod: Codable, Hashable, Equatable { - // MARK: Lifecycle - - init(package: String, target: String, tag: String) { - self.package = package - self.target = target - self.tag = tag - } - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - do { - let pod = try container.decode(String.self) - self = try Self.parse(pod: pod) - } catch { - let podGraph = try container.decode([String: [String]].self) - guard let pod = podGraph.keys.first else { - throw PodError.reason(""" - Parse Podfile.lock fail - Pod: \(podGraph) - """) - } - self = try Self.parse(pod: pod) - } - } - - // MARK: Internal - - let package: String - let target: String - let tag: String - - - static func == (lhs: Pod, rhs: Pod) -> Bool { - lhs.package == rhs.package - } - - - func hash(into hasher: inout Hasher) { - hasher.combine(package) - } - - // MARK: Fileprivate - - fileprivate var isDefaultSubSpec: Bool { - package == target - } - - - fileprivate func repo(lock: PodfileLock) async throws -> NewPodRepository { - guard - let external = lock.externals?[package], - let checkout = lock.checkouts?[package] - else { - return try await podSpec - } - return try PodRepository(pod: self, external: external, checkout: checkout) - } - - // MARK: Private - - /// pod spec cat Bagel --version=1.4.0 - private var podSpec: PodSpec { - get async throws { - let query = """ - ^\(package)$ - """ - .replacingOccurrences(of: "+", with: "\\+") - .replacingOccurrences(of: ".", with: "\\.") - let arg = "spec cat --regex \(query) --version=\(tag)" - - let data = try await Process.execute( - Env.pod, -// arguments: arg - arguments: "spec", "cat", "--regex", query, "--version=\(tag)") - do { - return try PodSpec.parse(data) - } catch { - print(""" - Parse Podspec Fail - `\(Env.pod) spec cat --regex \(query) --version=\(tag)` - \(arg) - string: \(String(data: data, encoding: .utf8) ?? "") - \(error) - - """) - throw error - } - } - } - - - /// AFNetworking (4.0.1) - /// AFNetworking/NSURLSession (4.0.1) - private static func parse(pod: String) throws -> PodfileLock.Pod { - let parts = pod.split(separator: " ").map(String.init) - guard parts.count == 2 else { - throw PodError.reason(""" - Parse Podfile.lock fail - version - Pod: \(pod) - """) - } - - let name = parts[0] - let tag = parts[1] - .replacingOccurrences(of: "(", with: "") - .replacingOccurrences(of: ")", with: "") - - let (package, target) = Util.parse(name: name) - - return .init(package: package, target: target, tag: tag) - } - } -} - -// MARK: PodfileLock.ExternalSource - -/// EXTERNAL SOURCES: -extension PodfileLock { - fileprivate struct ExternalSource: Codable { - let git: String - let tag: String? - let commit: String? - let branch: String? - - enum CodingKeys: String, CodingKey { - case git = ":git" - case tag = ":tag" - case commit = ":commit" - case branch = ":branch" - } - } -} - -// MARK: PodfileLock.CheckoutOption - -/// CHECKOUT OPTIONS: -extension PodfileLock { - fileprivate struct CheckoutOption: Codable { - let git: String - let tag: String? - let commit: String? - - enum CodingKeys: String, CodingKey { - case git = ":git" - case tag = ":tag" - case commit = ":commit" - } - } -} - -// MARK: PodfileLock.PodRepository - -extension PodfileLock { - fileprivate struct PodRepository: NewPodRepository { - // MARK: Lifecycle - - fileprivate init( - pod: PodfileLock.Pod, - external: PodfileLock.ExternalSource, - checkout: PodfileLock.CheckoutOption) throws - { - name = pod.package - let base = external.git.replacingOccurrences(of: ".git", with: "") - - /// branch > tag > commit - if let branch = external.branch { - url = "\(base)/archive/\(branch).zip" - return - } - - if let tag = external.tag ?? checkout.tag { - url = "\(base)/archive/\(tag).zip" - return - } - - if let commit = external.commit ?? checkout.commit { - url = "\(base)/archive/\(commit).zip" - return - } - - #warning("todo error design") - throw PodError.reason(""" - Parse Podfile.lock Error - Can't find git at \(pod.package) - """) - } - - // MARK: Internal - - let name: String - /// https://github.com/${organization,user}/${repo}/archive/${commit,branch,tag}.zip - let url: String - } -} diff --git a/Sources/Cocoapod/Pod.swift b/Sources/Cocoapod/Pod.swift deleted file mode 100644 index dbae020..0000000 --- a/Sources/Cocoapod/Pod.swift +++ /dev/null @@ -1,144 +0,0 @@ -// -// Pod.swift -// -// -// Created by Yume on 2022/4/26. -// - -import Foundation -import PathKit -import PluginLoader -import Util -import XCode - - -@_cdecl("createPlugin") -public func createPlugin() -> UnsafeMutableRawPointer { - Unmanaged.passRetained(_PluginBuilder()).toOpaque() -} - -// MARK: - _PluginBuilder - -final class _PluginBuilder: PluginBuilder { - override final func build(_ proj: Project) async throws -> Plugin? { - try await Pod.load(proj) - } -} - - -// MARK: - Pod - -public final class Pod { - // MARK: Lifecycle - - init(podfile: Podfile, lock: PodfileLock, repoCodes: [String]) { - self.podfile = podfile - self.lock = lock - self.repoCodes = repoCodes - } - - // MARK: Public - - public let name = "Cocoapod" - public let description = "Use `PodToBUILD`" - public let version = "0.0.1" - public let url = "https://github.com/XCodeBazelize/Bazelize" - - // MARK: Internal - - let podfile: Podfile - let lock: PodfileLock - - let repoCodes: [String] -} - -extension Pod { - // MARK: Public - - public static func parse(_ path: Path) async throws -> Pod? { - guard checkPodfile(path) else { return nil } - async let podfile = Podfile.process(path + "Podfile") - let lock = try PodfileLock.parse(path + "Podfile.lock") - async let codes = lock.repoCodes - return try await .init(podfile: podfile, lock: lock, repoCodes: codes) - } - - // MARK: Private - - private static func checkPodfile(_ path: Path) -> Bool { - let podfile = path + "Podfile" - let lock = path + "Podfile.lock" - switch (podfile.exists, lock.exists) { - case (true, true): - checkCommand() - return true - case (true, false): - print("Need Podfile.lock to check dependencies version") - exit(1) - default: - return false - } - } - - private static func checkCommand() { - guard Process.result(Env.pod, arguments: "--version") else { - print("Need install cocoapod or COCOAPOD=/xxx/pod") - exit(1) - } - } -} - -// MARK: Plugin - -extension Pod: Plugin { - // MARK: Public - - public static func load(_ proj: Project) async throws -> Pod? { - try await parse(proj.workspacePath) - } - - - public func workspace() -> String { - """ - # rules_pods - http_archive( - name = "rules_pods", - urls = ["https://github.com/pinterest/PodToBUILD/releases/download/4.1.0-412495/PodToBUILD.zip"], - # sha256 = "", - ) - - load("@rules_pods//BazelExtensions:workspace.bzl", "new_pod_repository") - """ - } - - /// "//Vendor/RxSwift:RxSwift", - /// "//Vendor/Alamofire:Alamofire", - public subscript(target: String) -> PluginTarget? { - PodPluginTarget(deps: podfile[target]) - } - - /// bazel run @rules_pods//:update_pods -- --src_root `PWD` - public func tip() { - print(""" - use bazel run @rules_pods//:update_pods -- --src_root `PWD` to install pod deps. - """) - } - - /// generate Pods.WORKSPACE - public func generateFile(_ rootPath: Path) throws { - let code = repoCodes.joined(separator: "\n\n") - let PodWorkspace = rootPath + "Pods.WORKSPACE" - print("Create \(PodWorkspace.string)") -// try to.delete() - try PodWorkspace.write(code) - } - - // MARK: Private - - private struct PodPluginTarget: PluginTarget { - let deps: [String] - var framework: [String] { - [] - } - } -} diff --git a/Sources/Cocoapod/PodError.swift b/Sources/Cocoapod/PodError.swift deleted file mode 100644 index 22353c3..0000000 --- a/Sources/Cocoapod/PodError.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// PodError.swift -// -// -// Created by Yume on 2022/5/11. -// - -import Foundation - -enum PodError: Error { - case reason(String) -} diff --git a/Sources/Cocoapod/Util.swift b/Sources/Cocoapod/Util.swift deleted file mode 100644 index cc4d9f9..0000000 --- a/Sources/Cocoapod/Util.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// Util.swift -// -// -// Created by Yume on 2022/5/11. -// - -import Foundation - -enum Util { - /// name: AFNetworking - /// package: AFNetworking - /// target: AFNetworking - /// - /// name AFNetworking/NSURLSession - /// package: AFNetworking - /// target: NSURLSession - static func parse(name: String) -> (package: String, target: String) { - let parts = name.split(separator: "/").map(String.init) - switch parts.count { - case 1: - return (name, name) - /// 2 up - default: - return (parts[0], parts[1]) - } - } -} diff --git a/Sources/PluginLoader/Core.swift b/Sources/PluginLoader/Core.swift index a631bef..67e9982 100644 --- a/Sources/PluginLoader/Core.swift +++ b/Sources/PluginLoader/Core.swift @@ -8,14 +8,14 @@ // import Foundation // import PathKit // import Util -// import XCode +// import Xcode // ///// start -> load xcode ///// start -> load plugin list ///// load plugin list -> build plugin ///// build plugin -> load plugin ///// load xcode -> load plugin -// public func load(manifest: Path, _ proj: Project) async throws -> [Plugin] { +// public func load(manifest: Path, _ proj: Xcode.Project) async throws -> [Plugin] { // guard manifest.exists else { // return [] // } diff --git a/Sources/PluginLoader/Plugin.swift b/Sources/PluginLoader/Plugin.swift index 42891a4..98e9ad5 100644 --- a/Sources/PluginLoader/Plugin.swift +++ b/Sources/PluginLoader/Plugin.swift @@ -7,14 +7,14 @@ import Foundation import PathKit -import XCode +import Xcode // MARK: - PluginBuilder open class PluginBuilder { public init() { } - open func build(_: Project) async throws -> Plugin? { + open func build(_: Xcode.Project) async throws -> Plugin? { fatalError("You have to override this method.") } } @@ -27,7 +27,7 @@ public protocol Plugin: AnyObject, Sendable { var version: String { get } var url: String { get } - static func load(_ proj: Project) async throws -> Self? + static func load(_ proj: Xcode.Project) async throws -> Self? subscript(_: String) -> PluginTarget? { get } diff --git a/Sources/PluginLoader/PluginBuilder.swift b/Sources/PluginLoader/PluginBuilder.swift index a1bd62d..e80b5c2 100644 --- a/Sources/PluginLoader/PluginBuilder.swift +++ b/Sources/PluginLoader/PluginBuilder.swift @@ -7,8 +7,8 @@ import Foundation @preconcurrency import PathKit -import SwiftCommand -import SystemPackage +import Subprocess +import System import Util // MARK: - PluginCompiler @@ -19,22 +19,24 @@ import Util enum PluginCompiler { // MARK: Internal - static func build(plugins: [PluginInfo]) throws -> [PluginInfo] { + static func build(plugins: [PluginInfo]) async throws -> [PluginInfo] { try git.mkpath() try build.mkpath() - return plugins.compactMap { info -> PluginInfo? in + var result: [PluginInfo] = [] + for info in plugins { if checkExist(plugin: info) { - return info + result.append(info) + continue } do { - try build(plugin: info) - return info + try await build(plugin: info) + result.append(info) } catch { Log.pluginLoader.warning("Build Plugin(\(info.repo)) Fail: \(error.localizedDescription)") - return nil } } + return result } // MARK: Private @@ -43,10 +45,6 @@ enum PluginCompiler { private static let git = root + "git" private static let build = root + "build" + swift - private static let commandGit = Command.findInPath(withName: "git") - private static let commandSwift = Command.findInPath(withName: "swift") - - private static func checkExist(plugin: PluginInfo) -> Bool { plugin.paths .map { (path: String) -> Path in @@ -70,27 +68,15 @@ enum PluginCompiler { /// git checkout tag /// swift build -c release /// cp .build/release/*.dylib build/XCodeBazelize_Bazelize/tag - private static func build(plugin: PluginInfo) throws { + private static func build(plugin: PluginInfo) async throws { let repo = git + plugin.user_repo if !repo.exists { - _ = try commandGit?.setCWD(FilePath(git.string)) - .addArguments("clone", plugin.url, plugin.user_repo) - .setStdout(.null) - .logging() - .wait() + try await run("git", "clone", plugin.url, plugin.user_repo, cwd: git) } - _ = try commandGit?.setCWD(FilePath(repo.string)) - .addArguments("checkout", plugin.tag) - .setStdout(.null) - .logging() - .wait() + try await run("git", "checkout", plugin.tag, cwd: repo) - _ = try commandSwift?.setCWD(FilePath(repo.string)) - .addArguments("build", "-c", "release") - .setStdout(.null) - .logging() - .wait() + try await run("swift", "build", "-c", "release", cwd: repo) let release = repo + ".build" + "release" @@ -105,12 +91,39 @@ enum PluginCompiler { } } -extension Command { - __consuming func logging() -> Self { +extension PluginCompiler { + fileprivate static func run( + _ executable: String, + _ arguments: String..., + cwd: Path) + async throws + { Log.pluginLoader.info(""" - \(cwd?.string ?? "")> \(executablePath) \(arguments.joined(separator: " ")) + \(cwd.string)> \(executable) \(arguments.joined(separator: " ")) """) - return self + let result = try await Subprocess.run( + .name(executable), + arguments: Arguments(arguments), + workingDirectory: FilePath(cwd.string), + output: .discarded, + error: .currentStandardError) + + guard result.terminationStatus.isSuccess else { + throw CommandError( + command: "\(executable) \(arguments.joined(separator: " "))", + status: result.terminationStatus) + } + } +} + +// MARK: - CommandError + +struct CommandError: Error, CustomStringConvertible { + let command: String + let status: TerminationStatus + + var description: String { + "`\(command)` failed with \(status)" } } diff --git a/Sources/PluginLoader/PluginLoader.swift b/Sources/PluginLoader/PluginLoader.swift index 7172b20..919043b 100644 --- a/Sources/PluginLoader/PluginLoader.swift +++ b/Sources/PluginLoader/PluginLoader.swift @@ -6,7 +6,7 @@ // import Foundation -import XCode +import Xcode private typealias InitFunction = @convention(c) () -> UnsafeMutableRawPointer @@ -25,7 +25,7 @@ enum PluginLoader { /// ## Package.swift /// --- /// - /// `.library(name: "Cocoapod", type: .dynamic, targets: ["Cocoapod"]),` + /// `.library(name: "YourPlugin", type: .dynamic, targets: ["YourPlugin"]),` /// /// ### Loadable Plugin Implement /// @@ -36,12 +36,12 @@ enum PluginLoader { /// } /// /// final class YourPluginBuilder: PluginBuilder { - /// override final func build(_ proj: Project) async throws -> Plugin? { - /// try await Pod.load(proj) + /// override final func build(_ proj: Xcode.Project) async throws -> Plugin? { + /// try await YourPlugin.load(proj) /// } /// } /// ``` - static func load(at path: String, proj: Project) async throws -> Plugin? { + static func load(at path: String, proj: Xcode.Project) async throws -> Plugin? { let openRes = dlopen(path, RTLD_NOW|RTLD_LOCAL) if openRes != nil { defer { diff --git a/Sources/RepoEnumCore/RepoEnumCore.swift b/Sources/RepoEnumCore/RepoEnumCore.swift new file mode 100644 index 0000000..38c3fe9 --- /dev/null +++ b/Sources/RepoEnumCore/RepoEnumCore.swift @@ -0,0 +1,306 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Yams + +// MARK: - RepoSource + +public struct RepoSource: Codable, Sendable, Equatable { + public let name: String + public let url: String + /// Bazel Central Registry module name. + /// + /// Present means versions come from the registry instead of the repository's + /// git tags, because `bazel_dep` can only resolve what the registry serves. + public let module: String? + + public init(name: String, url: String, module: String? = nil) { + self.name = name + self.url = url + self.module = module + } +} + +// MARK: - RepoVersionTag + +public struct RepoVersionTag: Sendable, Equatable { + public let normalizedVersion: String + public let caseName: String + private let components: [Int] + + public init?(rawTag: String) { + let pattern = #"^v?(\d+)\.(\d+)\.(\d+)$"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(rawTag.startIndex.. Int? in + guard let range = Range(match.range(at: index), in: rawTag) else { return nil } + return Int(rawTag[range]) + } + + guard values.count == 3 else { return nil } + + components = values + normalizedVersion = values.map(String.init).joined(separator: ".") + caseName = "v" + normalizedVersion.replacingOccurrences(of: ".", with: "_") + } + + public static func sortDescending(_ lhs: RepoVersionTag, _ rhs: RepoVersionTag) -> Bool { + lhs.components.lexicographicallyPrecedes(rhs.components) == false && lhs.components != rhs.components + ? true + : lhs.components == rhs.components ? lhs.normalizedVersion > rhs.normalizedVersion : false + } + + public static func sortedDescending(_ tags: [RepoVersionTag]) -> [RepoVersionTag] { + tags.sorted { lhs, rhs in + for (left, right) in zip(lhs.components, rhs.components) { + if left != right { + return left > right + } + } + return lhs.normalizedVersion > rhs.normalizedVersion + } + } +} + +// MARK: - GitHubTagFetching + +public protocol GitHubTagFetching: Sendable { + func tags(for repositoryURL: String) async throws -> [String] +} + +// MARK: - ModuleVersionFetching + +public protocol ModuleVersionFetching: Sendable { + func versions(forModule module: String) async throws -> [String] +} + +// MARK: - RepoEnumGeneratorError + +public enum RepoEnumGeneratorError: LocalizedError { + case invalidArguments(String) + case invalidGitHubURL(String) + case githubRequestFailed(statusCode: Int, message: String) + case registryRequestFailed(module: String, statusCode: Int) + + public var errorDescription: String? { + switch self { + case .invalidArguments(let message): + return message + case .invalidGitHubURL(let url): + return "Invalid GitHub repository URL: \(url)" + case .githubRequestFailed(let statusCode, let message): + return "GitHub API request failed (\(statusCode)): \(message)" + case .registryRequestFailed(let module, let statusCode): + return "Bazel Central Registry request for \(module) failed (\(statusCode))." + } + } +} + +// MARK: - RepoEnumFile + +public struct RepoEnumFile: Equatable { + public let source: RepoSource + public let tags: [RepoVersionTag] + + public init(source: RepoSource, tags: [RepoVersionTag]) { + var deduplicated: [String: RepoVersionTag] = [:] + for tag in tags { + deduplicated[tag.normalizedVersion] = tag + } + + self.source = source + self.tags = RepoVersionTag.sortedDescending(Array(deduplicated.values)) + } + + public var filename: String { + "BazelDep+\(source.name).swift" + } + + public var content: String { + let cases = tags.map { #" case \#($0.caseName) = "\#($0.normalizedVersion)""# } + .joined(separator: "\n") + + let latest = tags.first.map { tag in + " static let latest: \(source.name) = .\(tag.caseName)\n\n" + } ?? "" + + let body = cases.isEmpty ? "" : "\(latest)\(cases)\n" + return """ + extension BazelDep { + /// \(source.url) + enum \(source.name): String { + \(body) } + } + """ + } +} + +// MARK: - GitHubTagClient + +public struct GitHubTagClient: GitHubTagFetching { + private struct ResponseTag: Decodable { + let name: String + } + + private struct ErrorResponse: Decodable { + let message: String + } + + private let session: URLSession + private let token: String? + + public init( + session: URLSession = .shared, + token: String? = nil) + { + self.session = session + self.token = token ?? ProcessInfo.processInfo.environment["GITHUB_TOKEN"]?.nilIfEmpty + } + + public func tags(for repositoryURL: String) async throws -> [String] { + let repositoryPath = try Self.repositoryPath(from: repositoryURL) + var page = 1 + var allTags: [String] = [] + + while true { + let url = URL(string: "https://api.github.com/repos/\(repositoryPath)/tags?per_page=100&page=\(page)")! + var request = URLRequest(url: url) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("Bazelize RepoEnumPlugin", forHTTPHeaderField: "User-Agent") + if let token { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + let (data, response) = try await session.data(for: request) + + if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) { + let message = (try? JSONDecoder().decode(ErrorResponse.self, from: data).message) + ?? HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode) + throw RepoEnumGeneratorError.githubRequestFailed( + statusCode: httpResponse.statusCode, + message: message) + } + + let tags = try JSONDecoder().decode([ResponseTag].self, from: data) + if tags.isEmpty { + break + } + + allTags.append(contentsOf: tags.map(\.name)) + page += 1 + } + + return allTags + } + + static func repositoryPath(from repositoryURL: String) throws -> String { + guard let url = URL(string: repositoryURL), let host = url.host?.lowercased(), host == "github.com" else { + throw RepoEnumGeneratorError.invalidGitHubURL(repositoryURL) + } + + let parts = url.pathComponents.filter { $0 != "/" } + guard parts.count >= 2 else { + throw RepoEnumGeneratorError.invalidGitHubURL(repositoryURL) + } + + let owner = parts[0] + let repo = parts[1].replacingOccurrences(of: ".git", with: "") + return "\(owner)/\(repo)" + } +} + +// MARK: - BazelRegistryClient + +/// Reads published module versions from the Bazel Central Registry. +public struct BazelRegistryClient: ModuleVersionFetching { + private struct Metadata: Decodable { + let versions: [String] + let yanked_versions: [String: String]? + } + + private let session: URLSession + private let registry: URL + + public init( + session: URLSession = .shared, + registry: URL = URL(string: "https://bcr.bazel.build")!) + { + self.session = session + self.registry = registry + } + + public func versions(forModule module: String) async throws -> [String] { + let url = registry + .appendingPathComponent("modules") + .appendingPathComponent(module) + .appendingPathComponent("metadata.json") + + let (data, response) = try await session.data(from: url) + + if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) { + throw RepoEnumGeneratorError.registryRequestFailed( + module: module, + statusCode: httpResponse.statusCode) + } + + let metadata = try JSONDecoder().decode(Metadata.self, from: data) + let yanked = Set((metadata.yanked_versions ?? [:]).keys) + return metadata.versions.filter { !yanked.contains($0) } + } +} + +// MARK: - RepoEnumGeneratorService + +public struct RepoEnumGeneratorService { + private let client: GitHubTagFetching + private let registry: ModuleVersionFetching + private let decoder = YAMLDecoder() + private let fileManager = FileManager.default + + public init( + client: GitHubTagFetching = GitHubTagClient(), + registry: ModuleVersionFetching = BazelRegistryClient()) + { + self.client = client + self.registry = registry + } + + public func generate(configFile: URL, outputDirectory: URL) async throws { + let data = try Data(contentsOf: configFile) + let sources = try decoder.decode([RepoSource].self, from: String(decoding: data, as: UTF8.self)) + + try fileManager.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + + for source in sources { + let rawVersions = if let module = source.module { + try await registry.versions(forModule: module) + } else { + try await client.tags(for: source.url) + } + let tags = rawVersions.compactMap(RepoVersionTag.init(rawTag:)) + let file = RepoEnumFile(source: source, tags: tags) + let fileURL = outputDirectory.appendingPathComponent(file.filename) + try file.content.write(to: fileURL, atomically: true, encoding: .utf8) + } + } +} + +// MARK: - RepoEnumPaths + +public enum RepoEnumPaths { + public static func resolve(_ path: String, from base: URL, isDirectory: Bool = false) -> URL { + let url = URL(fileURLWithPath: path, isDirectory: isDirectory) + return url.path.hasPrefix("/") ? url : base.appendingPathComponent(path, isDirectory: isDirectory) + } +} + +extension String { + fileprivate var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/Sources/RepoEnumGenerator/Entry.swift b/Sources/RepoEnumGenerator/Entry.swift new file mode 100644 index 0000000..336e4c9 --- /dev/null +++ b/Sources/RepoEnumGenerator/Entry.swift @@ -0,0 +1,23 @@ +import ArgumentParser +import Foundation +import RepoEnumCore + +@main +struct RepoEnumGeneratorCommand: AsyncParsableCommand { + @Option(name: .long, help: "YAML config file listing repo enum sources.") + var config = "RepoSources.yml" + + @Option(name: .long, help: "Directory where generated Repo+*.swift files will be written.") + var output = "Generated" + + mutating func run() async throws { + let currentDirectory = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) + let configPath = RepoEnumPaths.resolve(config, from: currentDirectory) + let outputPath = RepoEnumPaths.resolve(output, from: currentDirectory, isDirectory: true) + + let service = RepoEnumGeneratorService() + try await service.generate( + configFile: configPath, + outputDirectory: outputPath) + } +} diff --git a/Sources/Starlark/Starlark/Value/Starlark+Value.swift b/Sources/Starlark/Starlark/Value/Starlark+Value.swift index 0b8f1f8..1ae31b4 100644 --- a/Sources/Starlark/Starlark/Value/Starlark+Value.swift +++ b/Sources/Starlark/Starlark/Value/Starlark+Value.swift @@ -9,8 +9,11 @@ extension Starlark { .custom(value) } - public static func glob(_ files: [String]) -> Value { - .glob(files) + /// `allowEmpty` is for a directory something else writes into: the pattern + /// stands for what will be there, and a package that cannot be loaded until + /// it is cannot be the thing that puts it there. + public static func glob(_ files: [String], exclude: [String] = [], allowEmpty: Bool = false) -> Value { + .glob(files, exclude: exclude, allowEmpty: allowEmpty) } public indirect enum Value: Sendable, Text { @@ -21,7 +24,7 @@ extension Starlark { case array([Value]) case dictionary([String: Value]) case select(Starlark.Select) - case glob([String]) + case glob([String], exclude: [String], allowEmpty: Bool) case custom(String) case none @@ -73,8 +76,13 @@ extension Starlark { case .label(let value): return value.text case .string(let value): + /// Values come from Xcode build settings and can carry quotes, e.g. + /// a preprocessor definition like `ID=@"com.example"`. + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") return """ - "\(value)" + "\(escaped)" """ case .int(let value): return "\(value)" @@ -99,11 +107,17 @@ extension Starlark { return value ? "True" : "False" case .select(let value): return value.text - case .glob(let files): + case .glob(let files, let exclude, let allowEmpty): let asset = Value(files.sorted()) ?? .none - return """ - glob(\(asset.text)) - """ + var arguments = [asset.text] + if !exclude.isEmpty { + let excluded = Value(exclude.sorted()) ?? .none + arguments.append("exclude = \(excluded.text)") + } + if allowEmpty { + arguments.append("allow_empty = True") + } + return "glob(\(arguments.joined(separator: ", ")))" case .custom(let value): return value case .none: diff --git a/Sources/Util/Env.swift b/Sources/Util/Env.swift deleted file mode 100644 index 4a05436..0000000 --- a/Sources/Util/Env.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// Env.swift -// -// -// Created by Yume on 2022/4/27. -// - -import Foundation - -public enum Env { - /// COCOAPOD - public static var pod: String { - ProcessInfo.processInfo.environment["COCOAPOD"] ?? "/usr/local/bin/pod" - } -} diff --git a/Sources/XCode/Model/BuildSetting+PList.swift b/Sources/XCode/Model/BuildSetting+PList.swift deleted file mode 100644 index 6d6681c..0000000 --- a/Sources/XCode/Model/BuildSetting+PList.swift +++ /dev/null @@ -1,312 +0,0 @@ -// -// BuildSetting+PList.swift -// -// -// Created by Yume on 2022/8/9. -// - -import Foundation -import PathKit -import Util - -// UISceneDelegateClassName -// $(PRODUCT_NAME).SceneDelegate -// - -// load("//build-system/bazel-utils:plist_fragment.bzl", -// "plist_fragment", -// ) - -// plist_fragment( -// name = "BuildNumberInfoPlist", -// extension = "plist", -// template = -// """ -// CFBundleVersion -// {buildNumber} -// """ -// ) - -private let PLIST_PREFIX = "INFOPLIST_KEY_" - -// MARK: - PLIST Key - - -// TODO: https://github.com/XCodeBazelize/Bazelize/issues/5 -extension BuildSettings { - // MARK: Public - - /// "YES" - public var generateInfoPlist: Bool { - let result: String? = self["GENERATE_INFOPLIST_FILE"] - return result == "YES" - } - - public var plistKeys: [String] { - setting.keys.filter { - $0.hasPrefix(PLIST_PREFIX) - } - } - - /// "ABCDEF/Info.plist" - public var infoPlist: String? { - self["INFOPLIST_FILE"] - } - - /// "LaunchScreen" - public var launch: String? { - self[plist: "UILaunchStoryboardName"] - } - - /// "Main" - public var storyboard: String? { - self[plist: "UIMainStoryboardFile"] - } - - // MARK: Private - - /// INFOPLIST_KEY_ - /// INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad - private subscript(plist key: String) -> String? { - self["\(PLIST_PREFIX)\(key)"] - } -} - -extension BuildSettings { - // MARK: Public - - public var defaultPlist: [String] { - let content: String? - if let plistPath = infoPlist { - let path: Path = project.workspacePath + plistPath - content = try? path.read() - } else { - content = nil - } - - let xmls = DefaultPlist.allCases.filter { (key: DefaultPlist) in - self[key.rawValue] == nil && - !(content?.contains(key.rawValue) ?? false) - }.map(\.xml).sorted() - return fillShortVersion(fillVersion(xmls)) - } - - // MARK: Fileprivate - - fileprivate enum DefaultPlist: String, CaseIterable { - case CFBundleName - case CFBundleIdentifier - case CFBundleVersion - case CFBundleExecutable - case CFBundlePackageType - case CFBundleDevelopmentRegion - case CFBundleShortVersionString - - // MARK: Fileprivate - - fileprivate var xml: String { - """ - \(rawValue) - \(value) - """ - } - - // MARK: Private - - private var value: String { - switch self { - case .CFBundleName: return "$(PRODUCT_NAME)" - case .CFBundleIdentifier: return "$(PRODUCT_BUNDLE_IDENTIFIER)" - case .CFBundleVersion: return "$(CURRENT_PROJECT_VERSION)" - case .CFBundleExecutable: return "$(EXECUTABLE_NAME)" - case .CFBundlePackageType: return "$(PRODUCT_BUNDLE_PACKAGE_TYPE)" - case .CFBundleDevelopmentRegion: return "$(DEVELOPMENT_LANGUAGE)" - case .CFBundleShortVersionString: return "$(MARKETING_VERSION)" - } - } - } - - // MARK: Private - - /// CFBundleVersion - CURRENT_PROJECT_VERSION - private var CURRENT_PROJECT_VERSION: String? { - self[#function] - } - - /// CFBundleShortVersionString - MARKETING_VERSION - private var MARKETING_VERSION: String? { - self[#function] - } - - private func fillVersion(_ xmls: [String]) -> [String] { - guard let version = CURRENT_PROJECT_VERSION else { return xmls } - return xmls.map { xml in - xml.replacingOccurrences(of: "$(CURRENT_PROJECT_VERSION)", with: version) - } - } - - private func fillShortVersion(_ xmls: [String]) -> [String] { - guard let version = MARKETING_VERSION else { return xmls } - return xmls.map { xml in - xml.replacingOccurrences(of: "$(MARKETING_VERSION)", with: version) - } - } -} - -/// GENERATE_INFOPLIST_FILE -extension BuildSettings { - // MARK: Public - - public var plist: [String] { - guard generateInfoPlist else { return [] } - - let xmls = plistKeys.sorted().flatMap { key -> [String?] in - let newKey = Self.key(key) - - guard let value = self[key] else { - return [Self.comment(newKey), nil] - } - - switch Decision(key) { - case .string: - return [newKey, Self.string(value)] - case .stringArray: - return [newKey, Self.stringArray(value)] - case .bool: - return [newKey, Self.bool(value)] - case .custom: - guard Self.isTrue(self[key] ?? "") else { return [] } - switch key { - case "INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone": - return [ - Self.key("INFOPLIST_KEY_UISupportedInterfaceOrientations~iPhone"), - Self.stringArray(value), - ] - case "INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad": - return [ - Self.key("INFOPLIST_KEY_UISupportedInterfaceOrientations~iPad"), - Self.stringArray(value), - ] - case "INFOPLIST_KEY_UIApplicationSceneManifest_Generation": - guard Self.isTrue(value) else { return [] } - return [ - Self.key("UIApplicationSceneManifest"), - """ - - UIApplicationSupportsMultipleScenes - - - """, - ] - case "INFOPLIST_KEY_UILaunchScreen_Generation": - guard Self.isTrue(value) else { return [] } - return [ - Self.key("UILaunchScreen"), - """ - - UILaunchScreen - - - """, - ] - default: return [] - } - case .unknown: - return [Self.comment(newKey), Self.comment(value)] - case .empty: - return [] - } - }.compactMap { $0 } - - return xmls - } - - // MARK: Fileprivate - - fileprivate enum Decision { - case string - case stringArray - case bool - case custom - case unknown - case empty - - // MARK: Lifecycle - - fileprivate init(_ key: String) { - switch key { - /// String - case "INFOPLIST_KEY_UIMainStoryboardFile": fallthrough - case "INFOPLIST_KEY_UILaunchStoryboardName": - self = .string - /// StringArray - case "INFOPLIST_KEY_UISupportedInterfaceOrientations": - self = .stringArray - /// Bool - case "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents": - self = .bool - /// Custom - case "INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone": fallthrough - case "INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad": fallthrough - case "INFOPLIST_KEY_UIApplicationSceneManifest_Generation": fallthrough - case "INFOPLIST_KEY_UILaunchScreen_Generation": - self = .custom - default: - self = .unknown - } - } - } - - /// NSAccentColorName - /// AccentColor -// private var ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: String? { -// self[#function] -// } -} - -extension BuildSettings { - // MARK: Internal - - static func isTrue(_ value: String) -> Bool { - value == "YES" - } - - static func bool(_ value: String) -> String { - isTrue(value) ? "" : "" - } - - static func string(_ value: String) -> String { - "\(value)" - } - - static func stringArray(_ value: String) -> String { - let strings = value - .split(separator: " ") - .map(String.init) - .compactMap(Self.string) - .withNewLine - .indent(1) - - return [ - "", - strings, - "", - ].withNewLine - } - - static func comment(_ value: String) -> String { - "" - } - - // MARK: Private - - /// transform - /// `INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad` - /// to - /// `UISupportedInterfaceOrientations~iPad` - private static func key(_ key: String) -> String { - let newKey = key - .delete(prefix: PLIST_PREFIX) - - return "\(newKey)" - } -} diff --git a/Sources/XCode/Model/BuildSettings.swift b/Sources/XCode/Model/BuildSettings.swift deleted file mode 100644 index ff4f2c5..0000000 --- a/Sources/XCode/Model/BuildSettings.swift +++ /dev/null @@ -1,238 +0,0 @@ -// -// BuildSetting.swift -// -// -// Created by Yume on 2022/4/29. -// - -import AnyCodable -import Foundation -import XcodeProj - -// MARK: - BuildSettings + Encodable - -extension BuildSettings: Encodable { - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(AnyCodable(setting)) - } -} - -// MARK: - BuildSettings - -public struct BuildSettings { - // MARK: Lifecycle - - init(_ project: Project, _ name: String, _ setting: [String: Any]) { - self.project = project - self.name = name - self.setting = setting - } - - init(_ project: Project, _ config: XCBuildConfiguration) { - self.init(project, config.name, config.buildSettings) - } - - // MARK: Public - - public unowned let project: Project - - /// Release / Debug / More... - public let name: String - public let setting: [String: Any] - - // MARK: Internal - - func merge(_ input: BuildSettings?) -> BuildSettings { - guard let input = input else { - return self - } - - let newSetting = setting.merging(input.setting) { first, _ in - first - } - - return .init(project, name, newSetting) - } - - internal subscript(key: String) -> String? { - let value = setting[key] - if let value = value as? String { - return value - } - - if let value = value as? BuildSetting { - switch value { - case .string(let string): - return string - case .array(let array): - return array.joined(separator: " ") - } - } - - return nil - } -} - -// MARK: - SDK - -public enum SDK: String, Encodable { - case iOS = "iphoneos" - case macOS = "macosx" - case tvOS = "appletvos" - case watchOS = "watchos" - case driverKit = "driverkit" - case auto -} - -// MARK: - DeviceFamily - -public enum DeviceFamily: String { - case iphone = "1" - case ipad = "2" - case appletv = "3" - case applewatch = "4" - case homepod = "5" - case mac = "6" - - public var code: String { - switch self { - case .iphone: return "iphone" - case .ipad: return "ipad" - case .appletv: return "appletv" - case .applewatch: return "applewatch" - case .homepod: return "homepod" - case .mac: return "mac" - } - } - - public static func parse(_ code: String?) -> [DeviceFamily] { - code?.split(separator: ",") - .map(String.init) - .compactMap(DeviceFamily.init(rawValue:)) ?? [] - } -} - -extension BuildSettings { - /// com.xxx.ABCDEF - public var bundleID: String? { - self["PRODUCT_BUNDLE_IDENTIFIER"] - } - - /// "37MR9UKGT3" - public var team: String? { - self["DEVELOPMENT_TEAM"] - } - - /// "5.0" - public var swiftVersion: String? { - self["SWIFT_VERSION"] - } - - // SUPPORTED_PLATFORMS - public var deviceFamily: [DeviceFamily] { - DeviceFamily.parse(self["TARGETED_DEVICE_FAMILY"]) - } - - /// SDKROOT - public var sdk: SDK? { - SDK(rawValue: self["SDKROOT"] ?? "") - } - - public var iOS: String? { - self["IPHONEOS_DEPLOYMENT_TARGET"] - } - - public var macOS: String? { - self["MACOSX_DEPLOYMENT_TARGET"] - } - - public var tvOS: String? { - self["TVOS_DEPLOYMENT_TARGET"] - } - - public var watchOS: String? { - self["WATCHOS_DEPLOYMENT_TARGET"] - } - - public var driverKit: String? { - self["DRIVERKIT_DEPLOYMENT_TARGET"] - } - - - - public var swiftDefine: String? { - self["OTHER_SWIFT_FLAGS"] - } - - /// ios application uitest `Target Application` - /// TEST_TARGET_NAME - public var testTargetName: String? { - self["TEST_TARGET_NAME"] - } - - /// ios application unittest `Host Application` - /// TEST_HOST - public var testHost: String? { - self["TEST_HOST"] - } - - /// ios application unittest `Allow testing Host Application APIs` - /// BUNDLE_LOADER - public var bundleLoader: String? { - self["BUNDLE_LOADER"] - } - - /// CLANG_ENABLE_MODULES - public var enableModules: Bool { - self["CLANG_ENABLE_MODULES"] == "YES" - } -} - -// MARK: - PLIST Value - -extension BuildSettings { - /// CFBundleName $(PRODUCT_NAME) - /// CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) - /// CFBundleExecutable $(EXECUTABLE_NAME) - /// CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) - /// CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) - /// CFBundleVersion $(CURRENT_PROJECT_VERSION) - /// CFBundleShortVersionString $(MARKETING_VERSION) 1.0 - // - key: "MARKETING_VERSION" - // - value: "1.0" - // - key: "CURRENT_PROJECT_VERSION" - // - value: "1" - -// UIApplicationSceneManifest....UISceneDelegateClassName -// $(PRODUCT_MODULE_NAME).SceneDelegate -// PRODUCT_MODULE_NAME -// $(PRODUCT_NAME:c99extidentifier) -// PRODUCT_NAME -// $(TARGET_NAME) -} - -// ▿ (2 elements) -// - key: "LD_RUNPATH_SEARCH_PATHS" -// ▿ value: 2 elements -// - "$(inherited)" -// - "@executable_path/Frameworks" -// ▿ (2 elements) -// - key: "INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone" -// - value: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" -// ▿ (2 elements) -// - key: "CODE_SIGN_STYLE" -// - value: "Automatic" - - -// - key: "ASSETCATALOG_COMPILER_APPICON_NAME" -// - value: "AppIcon" -// - key: "ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME" -// - value: "AccentColor" - -// - key: "SWIFT_EMIT_LOC_STRINGS" -// - value: "YES" - -// UILaunchStoryboardName -// LaunchScreen -// UIMainStoryboardFile -// Main diff --git a/Sources/XCode/Model/ConfigList.swift b/Sources/XCode/Model/ConfigList.swift deleted file mode 100644 index 3374e26..0000000 --- a/Sources/XCode/Model/ConfigList.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// ConfigList.swift -// -// -// Created by Yume on 2022/7/1. -// - -import Foundation -import XcodeProj - -// MARK: - ConfigList - -struct ConfigList { - // MARK: Lifecycle - - init(_ project: Project, _ target: PBXNativeTarget) { - self.init(project, target.buildConfigurationList) - } - - init(_ project: Project, _ list: XCConfigurationList?) { - self.project = project - native = list - } - - // MARK: Public - - public unowned let project: Project - - // MARK: Internal - - var buildSettings: [String: BuildSettings] { - let pair: [(String, BuildSettings)] = native?.buildConfigurations - .map { - BuildSettings(project, $0) - }.map { - ($0.name, $0) - } ?? [] - - return pair.toDictionary() - } - - /// For XCode Target ConfigList merge default ConfigList - func merge(_ config: ConfigList?) -> [String: BuildSettings] { - guard let config = config else { - return buildSettings - } - - let `default` = config.buildSettings - return buildSettings.mapValues { setting in - setting.merge(`default`[setting.name]) - } - } - - // MARK: Private - - private let native: XCConfigurationList? -} - -// MARK: Hashable - -extension ConfigList: Hashable { - public static func == (lhs: ConfigList, rhs: ConfigList) -> Bool { - lhs.native?.uuid == rhs.native?.uuid - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(native?.uuid) - } -} diff --git a/Sources/XCode/Model/SPMParser.swift b/Sources/XCode/Model/SPMParser.swift deleted file mode 100644 index b486081..0000000 --- a/Sources/XCode/Model/SPMParser.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// SPMParser.swift -// -// -// Created by Yume on 2023/1/19. -// - -import Foundation - -import Basics -import PathKit -import TSCBasic -import Workspace - -public enum SPMParser { - public static func parse(path: String) throws -> (products: [String: [String]], targets: [String]) { - let packagePath = try Basics.AbsolutePath(validating: path) - let observability = ObservabilitySystem { _,_ in } - - let workspace = try Workspace(forRootPackage: packagePath) - let manifest = try tsc_await { - workspace.loadRootManifest( - at: packagePath, - observabilityScope: observability.topScope, - completion: $0) - } - - let pair = manifest.products.map { ($0.name, $0.targets) } - let products = pair.toDictionary() - let targets = manifest.targets.map { $0.name } - - let productsDetail = products.map { key, value in - """ - \(key): - \(value.withNewLine.indent(1)) - """.indent(2) - }.sorted().withNewLine - - print(""" - Find Local SPM - at: \(path) - products: - \(productsDetail) - """) - - return (products, targets) - } - - public static func allPackageNames(path: String) async throws -> [String] { - let packagePath = try Basics.AbsolutePath(validating: path) - let observability = ObservabilitySystem { _,_ in } - - let workspace = try Workspace(forRootPackage: packagePath) - let graph = try await workspace.loadPackageGraph( - rootPath: packagePath, - observabilityScope: observability.topScope) - - return graph.packages.filter { package in - !graph.isRootPackage(package) - }.map(\.manifest.displayName) - } -} diff --git a/Sources/XCode/Model/XCode+File.swift b/Sources/XCode/Model/XCode+File.swift deleted file mode 100644 index 5bb4740..0000000 --- a/Sources/XCode/Model/XCode+File.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// RelativePath.swift -// -// -// Created by Yume on 2022/4/25. -// - -import Foundation -import PathKit -import XcodeProj - -// MARK: - File - -public final class File { - private unowned let project: Project - let native: PBXFileElement - - init(native: PBXFileElement, project: Project) { - self.native = native - self.project = project - } - - /// root: /Users/xxx/git/ABCDEF - /// - /// fullPath: /Users/xxx/git/ABCDEF/DEF/Base.lproj/LaunchScreen.storyboard - /// package: DEF - public var label: String? { - guard let path = relativePath else { - return nil - } - return project.transformToLabel(path) - } - - public var relativePath: String? { - let root = project.workspacePath.string - let fullPath = fullPath ?? "" - guard fullPath.hasPrefix(root + "/") else { - return nil - } - return fullPath.delete(prefix: root + "/") - } - - public var fullPath: String? { - let root = project.workspacePath.string - return try? native.fullPath(sourceRoot: root) - } - - private var ref: PBXFileReference? { - native as? PBXFileReference - } - - /// File type start with `sourcecode.` - public var isSource: Bool { - guard let ref = ref else { return false } - return ref.lastKnownFileType?.hasPrefix("sourcecode.") ?? false - } - - /// File is PBXFileReference - public var isFile: Bool { - native is PBXFileReference - } - - public var lastKnownFileType: LastKnownFileType? { - .init(rawValue: ref?.lastKnownFileType ?? "") - } - - public var explicitFileType: ExplicitFileType? { - .init(rawValue: ref?.explicitFileType ?? "") - } -} - -extension PBXFileElement { - func flatten() -> [PBXFileElement] { - if let group = self as? PBXGroup { - return group.children.flatMap { file in - file.flatten() - } - } - - if let ref = self as? PBXFileReference { - return [ref] - } - - return [] - } -} - -extension Array where Element == File { - var labels: [String] { - compactMap(\.label) - } -} diff --git a/Sources/XCode/Model/XCode+Preffer.swift b/Sources/XCode/Model/XCode+Preffer.swift deleted file mode 100644 index c8885ab..0000000 --- a/Sources/XCode/Model/XCode+Preffer.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// XCode+Prefer.swift -// -// -// Created by Yume on 2022/8/23. -// - -import Foundation -import Starlark -import XcodeProj - -extension Dictionary where Key == String { - fileprivate var sortedByKey: [(key: Key, value: Value)] { - sorted { lhs, rhs in - lhs.key < rhs.key - } - } -} - -extension Dictionary where Key == String { - public func prefer(config: String?, _ keyPath: KeyPath) -> T? { - let firstValue = sortedByKey.first?.value[keyPath: keyPath] - guard let key = config else { return firstValue } - let preferValue = self[key]?[keyPath: keyPath] - return preferValue ?? firstValue - } - - // prevent T?? - public func prefer(config: String?, _ keyPath: KeyPath) -> T? { - let firstValue = sortedByKey.first?.value[keyPath: keyPath] - guard let key = config else { return firstValue } - let preferValue = self[key]?[keyPath: keyPath] - return preferValue ?? firstValue - } -} - -extension Target { - public func prefer(_ keyPath: KeyPath) -> T? { - config.prefer(config: project.preferConfig, keyPath) - } - - // prevent T?? - public func prefer(_ keyPath: KeyPath) -> T? { - config.prefer(config: project.preferConfig, keyPath) - } -} diff --git a/Sources/XCode/Model/XCode+Project.swift b/Sources/XCode/Model/XCode+Project.swift deleted file mode 100644 index 04a87cd..0000000 --- a/Sources/XCode/Model/XCode+Project.swift +++ /dev/null @@ -1,160 +0,0 @@ -// -// Project.swift -// -// -// Created by Yume on 2022/4/21. -// - -import AnyCodable -import Foundation -import PathKit -import XcodeProj - -// MARK: - Project + Encodable - -extension Project: Encodable { - // MARK: Public - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: Keys.self) - try container.encode(workspacePath.string, forKey: .workspacePath) - try container.encode(projectPath.string, forKey: .projectPath) - - try container.encode(localSPM, forKey: .localSPM) -// try container.encode(_spm, forKey: .spm) - - try container.encode(_targets, forKey: .targets) - try container.encode(AnyCodable(config), forKey: .config) - } - - // MARK: Internal - - enum Keys: String, CodingKey { - case workspacePath - case projectPath - case localSPM - case spm - case targets - case config - } -} - -// MARK: - Project - -public final class Project { - private let project: XcodeProj - private let native: PBXProj - - public let workspacePath: Path - public let projectPath: Path - public let preferConfig: String? - - private var packages: [String] = [] - - public init(_ projectPath: Path, _ preferConfig: String?) async throws { - let path = projectPath.parent() - workspacePath = path - self.projectPath = projectPath - project = try XcodeProj(path: projectPath) - native = project.pbxproj - self.preferConfig = preferConfig - } - - public var all: [File] { - let all = try? native.rootGroup()?.flatten().map { file in - File(native: file, project: self) - } - - return all ?? [] - } - - public func files(_ type: LastKnownFileType) -> [File] { - all.filter { file in - file.lastKnownFileType == type - } - } - - public lazy var remoteSPM: [XCodeRemoteSPM] = native.frameworksBuildPhases - .compactMap(\.files) - .flatMap { $0 } - .compactMap(\.product) - .compactMap(XCodeRemoteSPM.parse) - - public lazy var localSPM: [XCodeLocalSPM] = (files(.wrapper) + files(.folder)).compactMap { file in - guard let path = file.relativePath else { return nil } - guard let fullPath = file.fullPath else { return nil } - guard let (products, targets) = try? SPMParser.parse(path: fullPath) else { return nil } - return XCodeLocalSPM(path: path, products: products, targets: targets) - } - - public lazy var frameworks: [File] = native.frameworksBuildPhases - .compactMap(\.files) - .flatMap { $0 } - .compactMap(\.file) - .map { file in - File(native: file, project: self) - } - - private lazy var _targets: [Target] = { - let list = defaultConfigList - return native.nativeTargets.map { - Target(native: $0, defaultConfigList: list, project: self) - } - }() - - public var targets: [Target] { - _targets - } - - public var config: [String: BuildSettings]? { - defaultConfigList?.buildSettings - } -} - -extension Project { - public func transformToLabel(_ relativePath: String?) -> String? { - guard let path = relativePath else { return nil } - - let commentedLabel = """ - # \(path) - """ - guard let _package = path.split(separator: "/").first else { - return commentedLabel - } - let package = String(_package) - guard let restPath = path.delete(prefix: package + "/") else { - return commentedLabel - } - - if check(package) { - return """ - //\(package):\(restPath) - """ - } else { - return """ - //:\(package)/\(restPath) - """ - } - } - - private var defaultConfigList: ConfigList? { - let all = Set(native.configurationLists.map { ConfigList(self, $0) }) - let targets = native.nativeTargets - .compactMap { ConfigList(self, $0.buildConfigurationList) } - - return all.subtracting(targets).first - } - - private typealias Package = String - - private func check(_ package: Package) -> Bool { - targets.map(\.name).contains(package) - } -} - -extension String { - fileprivate func delete(prefix: String) -> String? { - guard hasPrefix(prefix) else { return nil } - return String(dropFirst(prefix.count)) - } -} diff --git a/Sources/XCode/Model/XCode+RemoteSPMPackage.swift b/Sources/XCode/Model/XCode+RemoteSPMPackage.swift deleted file mode 100644 index 65b9954..0000000 --- a/Sources/XCode/Model/XCode+RemoteSPMPackage.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// XCodeSPM.swift -// -// -// Created by Yume on 2022/4/25. -// - -import Foundation -import Util -import XcodeProj - -extension XCodeRemoteSPM { - public static func parse(_ native: XCSwiftPackageProductDependency) -> XCodeRemoteSPM? { - let package = native.package - guard - let url = package?.repositoryURL, - let requirement = package?.versionRequirement - else { - return nil - } - return XCodeRemoteSPM(url: url, version: requirement.version) - } -} - -extension XCRemoteSwiftPackageReference.VersionRequirement { - var version: XCodeRemoteSPM.Version { - switch self { - case .upToNextMajorVersion(let version): return .upToNextMajorVersion(version) - case .upToNextMinorVersion(let version): return .upToNextMinorVersion(version) - case .range(let from, let to): return .range(from: from, to: to) - case .exact(let version): return .exact(version) - case .branch(let branch): return .branch(branch) - case .revision(let commit): return .revision(commit) - } - } -} diff --git a/Sources/XCode/Model/XCode+SPM.swift b/Sources/XCode/Model/XCode+SPM.swift deleted file mode 100644 index 10b36f8..0000000 --- a/Sources/XCode/Model/XCode+SPM.swift +++ /dev/null @@ -1,249 +0,0 @@ -// -// XCodeSPM.swift -// -// -// Created by Yume on 2022/4/25. -// - -import Foundation -import PathKit -import Util -import XcodeProj - -extension Array where Element == Target { - // MARK: Public - - public var isHaveSPM: Bool { - !flatPackages.isEmpty - } - - - public func spm_pkgs(_ path: Path? = nil) -> String { - flatPackages.spm_pkgs(path) - } - - public func spm_repositories(_ path: Path? = nil) -> String { - """ - load("@cgrindel_rules_spm//spm:defs.bzl", "spm_pkg", "spm_repositories") - - spm_repositories( - name = "swift_pkgs", - dependencies = [ - \(spm_pkgs(path).indent(2)) - ], - ) - """ - } - - // MARK: Private - - private var flatPackages: [Package] { - flatMap(\.native.spm) - } -} - -extension Array where Element == Package { - /// Target -> Target.Name - /// Package -> Target - /// Set -> deps - private var mapping: [String: (Package, Set)] { - var dict: [String: (Package, Set)] = [:] - for package in self { - guard let url = package.product.package?.repositoryURL else { continue } - if var (_, set) = dict[url] { - set.insert(package.product.productName) - dict[url] = (package, set) - } else { - let set = Set(arrayLiteral: package.product.productName) - dict[url] = (package, set) - } - } - - return dict - } - - private func spm_pkgs(_ path: Path? = nil) -> String { - let resolved: Package.Resolved? - if let projRoot = path { - let resolvedPath = projRoot + "project.xcworkspace/xcshareddata/swiftpm/Package.resolved" - resolved = try? .parse(resolvedPath) - } else { - resolved = nil - } - - return mapping.compactMap { _, value -> String? in - let (package, set) = value - return package.spm_pkg(set, resolved) - }.withNewLine - } -} - -// MARK: - Package - -// path A local path string to the package repository. None -// name Optional. The name (string) to be used for the package in Package.swift. None -private struct Package { - /// xxx.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved - /// { - /// "pins" : [ - /// { - /// "identity" : "alertkit", - /// "kind" : "remoteSourceControl", - /// "location" : "https://github.com/EhPanda-Team/AlertKit.git", - /// "state" : { - /// "branch" : "custom", - /// "revision" : "39b01c53ffadf3dab9871dd4c960cd81af5246b6" - /// } - /// }, - /// "version" : 2 - /// } - /// { - /// "object": { - /// "pins": [ - /// { - /// "package": "Rainbow", - /// "repositoryURL": "https://github.com/onevcat/Rainbow", - /// "state": { - /// "branch": null, - /// "revision": "626c3d4b6b55354b4af3aa309f998fae9b31a3d9", - /// "version": "3.2.0" - /// } - /// }, - /// "version" : 1 - /// } - struct Resolved: JSONParsable { - struct Object: Codable { - let pins: [Pin1] - } - - struct Pin1: Codable { - /// Rainbow - let package: String - let state: State - } - - struct Pin2: Codable { - /// alertkit - let identity: String - let state: State - } - - struct State: Codable { - let revision: String - } - - let version: Int - let object: Object? - let pins: [Pin2]? - - - subscript(_ name: String) -> String? { - switch version { - case 1: - return object?.pins.first { $0.package == name }?.state.revision - case 2: - return pins?.first { $0.identity == name.lowercased() }?.state.revision - default: - return nil - } - } - } - - let product: XCSwiftPackageProductDependency - - /// "@swift_pkgs//swift-log:Logging", - var dep: String? { - /// https://github.com/apple/swift-log.git - guard let url = product.package?.repositoryURL else { return nil } - let path = Path(url) - /// swift-log - let repo = path.lastComponentWithoutExtension - - /// Logging - let product = product.productName - - return """ - "@swift_pkgs//\(repo):\(product)", - """ - } - - /// exact_version Optional. A string representing a valid "exact" SPM version. None - /// from_version Optional. A string representing a valid "from" SPM version. None - /// revision Optional. A commit hash (string). None - func version(_ resolved: Resolved?) -> String? { - switch product.package?.versionRequirement { - case .exact(let ver): - return """ - exact_version = "\(ver)" - """ - case .range(let from, _): fallthrough - case .upToNextMajorVersion(let from): fallthrough - case .upToNextMinorVersion(let from): - return """ - from_version = "\(from)" - """ - case .revision(let commit): - return """ - revision = "\(commit)" - """ - case .branch(let branch): - guard let commit = resolved?[product.package?.name ?? ""] else { return nil } - return """ - # branch `\(branch)` - revision = "\(commit)" - """ - default: return nil - } - } - - - /// spm_pkg( - /// "https://github.com/apple/swift-log.git", - /// exact_version = "1.4.2", - /// products = ["Logging"], - /// ), - /// - /// url A string representing the URL for the package repository. None - /// - /// products A list of string values representing the names of the products to be used. [] - func spm_pkg(_ set: Set, _ resolved: Package.Resolved?) -> String? { - guard let url = product.package?.repositoryURL else { return nil } - guard let version = version(resolved) else { return nil } - let products = set.map { product in - """ - "\(product)" - """ - }.joined(separator: " ,") - - return """ - spm_pkg( - "\(url)", - \(version), - products = [\(products)], - ), - """ - } -} - -extension PBXNativeTarget { - // MARK: Public - - /// use for `deps` - public var spm_deps: String { - spm - .compactMap(\.dep) - .withNewLine - } - - // MARK: Fileprivate - - fileprivate var spm: [Package] { - _spm.map(Package.init) - } - - // MARK: Private - - private var _spm: [XCSwiftPackageProductDependency] { - (try? frameworksBuildPhase()?.files?.compactMap(\.product)) ?? [] - } -} diff --git a/Sources/XCode/Model/XCode+Select.swift b/Sources/XCode/Model/XCode+Select.swift deleted file mode 100644 index da3eb44..0000000 --- a/Sources/XCode/Model/XCode+Select.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// Target+Select.swift -// -// -// Created by Yume on 2022/8/17. -// - -import Foundation -import Starlark -import XcodeProj - -extension Dictionary where Key == String { - // MARK: Public - - public func select(_ keypath: KeyPath) -> Starlark.Select { - if checkSame(keypath) { - return selectSame(keypath) - } - return selectVarious(keypath) - } - - // MARK: Private - - private func checkSame(_ keypath: KeyPath) -> Bool { - let values: [T] = map { _, setting in - setting[keyPath: keypath] - } - - return Set(values).count == 1 - } - - private func selectSame(_ keypath: KeyPath) -> Starlark.Select { - guard let value = first?.value[keyPath: keypath] else { - return selectVarious(keypath) - } - return .same(value) - } - - private func selectVarious(_ keypath: KeyPath) -> Starlark.Select { - let result: [Starlark.Label: T] = reduce(into: [:]) { partialResult, entry in - partialResult[.config(entry.key)] = entry.value[keyPath: keypath] - } - return .various(result) - } -} - -extension Target { - public func select(_ keypath: KeyPath) -> Starlark.Select { - config.select(keypath) - } -} diff --git a/Sources/XCode/Model/XCode+Target.swift b/Sources/XCode/Model/XCode+Target.swift deleted file mode 100644 index de819b7..0000000 --- a/Sources/XCode/Model/XCode+Target.swift +++ /dev/null @@ -1,302 +0,0 @@ -// -// Target.swift -// -// -// Created by Yume on 2022/4/25. -// - -import AnyCodable -import Foundation -import Starlark -import XcodeProj - -// MARK: - Target + Encodable - -extension Target: Encodable { - // MARK: Public - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: Keys.self) - try container.encode(name, forKey: .name) - try container.encode(AnyCodable(originConfig), forKey: .config) - try container.encode(headers, forKey: .headers) - try container.encode(srcs, forKey: .srcs) - try container.encode(resources, forKey: .resources) - try container.encode(importFrameworks, forKey: .importFrameworks) - // try container.encode(frameworks, forKey: .frameworks) - // try container.encode(frameworks_library, forKey: .frameworks_library) - // try container.encode(sdkFrameworks, forKey: .sdkFrameworks) - } - - // MARK: Internal - - enum Keys: String, CodingKey { - case name - case config - case headers - case srcs - case resources - case importFrameworks - case frameworks - case frameworks_library - case sdkFrameworks - } -} - -// MARK: - Target - -public final class Target { - // MARK: Lifecycle - - init(native: PBXNativeTarget, defaultConfigList: ConfigList?, project: Project) { - self.native = native - let configList: ConfigList = .init(project, native.buildConfigurationList) - let defaultConfigList = defaultConfigList - originConfig = configList.buildSettings - config = configList.merge(defaultConfigList) - self.project = project - } - - // MARK: Public - - public let native: PBXNativeTarget - public let config: [String: BuildSettings] - - public unowned let project: Project - - public var configs: [String] { - config.keys.sorted { lhs, rhs in - lhs < rhs - } - } - - public var name: String { native.name } - - - public subscript(config: String) -> BuildSettings? { - self.config[config] - } - - // MARK: Private - - private let originConfig: [String: BuildSettings] -} - -extension Target { - // MARK: Internal - - func isInPackage(_ label: String) -> Bool { - label.hasPrefix(""" - //\(name): - """) - } - - // MARK: Private - - private func files(_ files: [PBXBuildFile]?) -> [File] { - files?.flatMap { build -> [File] in - guard let files = build.file?.flatten() else { return [] } - return files.map { native -> File in - File(native: native, project: project) - } - } ?? [] - } -} - -/// srcs -extension Target { - // MARK: Public - - /// `.h` & `.pch` - public var headers: [String] { - project - .files(.h) - .labels - .filter(isInPackage) - } - - public var hpps: [String] { - project - .files(.hpp) - .labels - .filter(isInPackage) - } - - public var srcFiles: [File] { - files(try? native.sourcesBuildPhase()?.files) - } - - public var srcs: [String] { - srcFiles.labels - } - - public var srcs_c: [String] { - srcs(.c) - } - - public var srcs_objc: [String] { - srcs(.objc) - } - - public var srcs_cpp: [String] { - srcs(.cpp) - } - - public var srcs_objcpp: [String] { - srcs(.objcpp) - } - - public var srcs_swift: [String] { - srcs(.swift) - } - - public var srcs_metal: [String] { - srcs(.metal) - } - - // MARK: Internal - - func srcs(_ type: LastKnownFileType) -> [String] { - srcFiles.filter { file in - file.lastKnownFileType == type - }.labels - } -} - -extension Target { - // MARK: Public - - public var resourceFiles: [File] { - files(try? native.resourcesBuildPhase()?.files) - } - - public var xibs: [String] { - resources(.xib) - } - - public var storyboards: [String] { - resources(.storyboard) - } - - public var assets: [String] { - resources(.asset) - } - - public var strings: [String] { - resources(.strings) - } - - public var stringsdict: [String] { - resources(.stringsdict) - } - - public var allStrings: [String] { - strings + stringsdict - } - - public var resources: [String] { - resourceFiles.labels - } - - // MARK: Internal - - func resources(_ type: LastKnownFileType) -> [String] { - resourceFiles.filter { file in - file.lastKnownFileType == type - }.labels - } -} - -extension Target { - // MARK: Public - - /// https://github.com/XCodeBazelize/Bazelize/issues/8 - /// use for `frameworks` - public var importFrameworks: [String] { - _frameworks.compactMap(\.relativePath) - } - - public var frameworksLibrary: [Starlark.Label] { - _frameworksTarget - .map { target -> String in - let name = target.name - return """ - //\(name):\(name)_library - """ - } - .sorted() - .map { - Starlark.Label.named($0) - } - } - - public var frameworks: [Starlark.Label] { - _frameworksTarget - .map { target -> String in - let name = target.name - return """ - //\(name):\(name) - """ - } - .sorted() - .map { - Starlark.Label.named($0) - } - } - - /// use for `sdk_frameworks` - /// - /// name - /// nil // XCode Target - /// AVFoundation.framework // SDK - /// path - /// Framework2.framework - /// Platforms/MacOSX.platform/Developer/SDKs/ - /// MacOSX13.1.sdk/System/Library/Frameworks/AVFoundation.framework - /// - /// Target(SDK) - /// AVFoundation.framework -> AVFoundation - public var frameworksSDK: [String] { - _frameworks - .compactMap(\.native.name) - .filter { name in - name.hasSuffix(".framework") - } - .map { (name: String) in - name.replacingOccurrences(of: ".framework", with: "") - } - } - - // MARK: Private - - // use for `frameworks` - private var _frameworks: [File] { - let builds = (try? native.frameworksBuildPhase()?.files?.compactMap(\.file)) ?? [] - return builds.map { - File(native: $0, project: project) - } - } - - private var _frameworksTarget: [Target] { - let frameworks = _frameworks.map(\.native) - let targets = project.targets - - return targets.filter { target in - guard let product = target.native.product else { - return false - } - return frameworks.contains(product) - } - } -} - -extension String { - /// .swift - /// .m - /// .mm - func hasExtension(_ type: String) -> Bool { - hasSuffix(""" - \(type) - """) - } -} diff --git a/Sources/XCode/Model/XCodeSPM.swift b/Sources/XCode/Model/XCodeSPM.swift deleted file mode 100644 index 009d489..0000000 --- a/Sources/XCode/Model/XCodeSPM.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// XCodeSPM.swift -// -// -// Created by Yume on 2022/7/29. -// - -import Foundation - -// MARK: - XCodeLocalSPM - -public struct XCodeLocalSPM: Encodable { - public let path: String - public let products: [String: [String]] - public let targets: [String] - - public init(path: String, products: [String: [String]], targets: [String]) { - self.path = path - self.products = products - self.targets = targets - } - - public var package: String { - """ - .package(path: "\(path)"), - """ - } -} - -// MARK: - XCodeRemoteSPM - -public struct XCodeRemoteSPM: Encodable { - public let url: String - public let version: Version - - public init(url: String, version: XCodeRemoteSPM.Version) { - self.url = url - self.version = version - } - - public enum Version: Encodable { - case upToNextMajorVersion(String) - case upToNextMinorVersion(String) - case range(from: String, to: String) - case exact(String) - case branch(String) - case revision(String) - - var version: String { - switch self { - case .upToNextMajorVersion(let version): - return """ - from: "\(version)" - """ - case .upToNextMinorVersion(let version): - return """ - from: "\(version)" - """ - case .range(let from, let to): - return """ - "\(from)"..."\(to)" - """ - case .exact(let version): - return """ - exact: "\(version)" - """ - case .branch(let branch): - return """ - branch: "\(branch)" - """ - case .revision(let revision): - return """ - revision: \(revision) - """ - } - } - } - - public var package: String { - """ - .package(url: "\(url)", \(version.version)), - """ - } -} diff --git a/Sources/XCode/Setting/DeviceFamily.swift b/Sources/XCode/Setting/DeviceFamily.swift deleted file mode 100644 index d402cb2..0000000 --- a/Sources/XCode/Setting/DeviceFamily.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// File.swift -// -// -// Created by Yume on 2022/7/1. -// - -import Foundation - -// MARK: - SupportedPlatform - -/// SUPPORTED_PLATFORMS -enum SupportedPlatform: String { - case iphonesimulator - case iphoneos - case driverkit - case macosx - case appletvsimulator - case appletvos - case watchsimulator - case watchos -} - - -/// catalyst -/// SUPPORTS_MACCATALYST - -/// design for ipad -/// SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD diff --git a/Sources/XCode/Setting/ExplicitFileType.swift b/Sources/XCode/Setting/ExplicitFileType.swift deleted file mode 100644 index 5c57235..0000000 --- a/Sources/XCode/Setting/ExplicitFileType.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// ExplicitFileType.swift -// -// -// Created by Yume on 2023/1/18. -// - -import Foundation - -/// `PBXFileReference.explicitFileType` -public enum ExplicitFileType: String, Equatable { - /// libXXX.a - case archive = "archive.ar" - - case framework = "wrapper.framework" -} diff --git a/Sources/XCode/Setting/LastKnownFileType.swift b/Sources/XCode/Setting/LastKnownFileType.swift deleted file mode 100644 index d9cdb68..0000000 --- a/Sources/XCode/Setting/LastKnownFileType.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// LastKnownFileType.swift -// -// -// Created by Yume on 2022/8/4. -// - -import Foundation - -/// `PBXFileReference.lastKnownFileType` -public enum LastKnownFileType: String { - case h = "sourcecode.c.h" - case c = "sourcecode.c.c" - case objc = "sourcecode.c.objc" - case hpp = "sourcecode.cpp.h" - case cpp = "sourcecode.cpp.cpp" - case objcpp = "sourcecode.cpp.objcpp" - case swift = "sourcecode.swift" - case metal = "sourcecode.metal" - - case strings = "text.plist.strings" - case stringsdict = "text.plist.stringsdict" - case plist = "text.plist.xml" - case entitlements = "text.plist.entitlements" - case xcconfig = "text.xcconfig" - - case asset = "folder.assetcatalog" - - case xib = "file.xib" - case storyboard = "file.storyboard" - - case wrapper - case folder - - case framework = "wrapper.framework" - case xcframework = "wrapper.xcframework" -} diff --git a/Sources/Xcode/Loader/Xcode+ConfigListLoader.swift b/Sources/Xcode/Loader/Xcode+ConfigListLoader.swift new file mode 100644 index 0000000..2a5c938 --- /dev/null +++ b/Sources/Xcode/Loader/Xcode+ConfigListLoader.swift @@ -0,0 +1,93 @@ +import Foundation +import PathKit +import XcodeProj + +struct ConfigListLoader: Hashable { + let native: XCConfigurationList? + let sourceRoot: Path + + var configs: [String: Xcode.BuildSettings] { + (native?.buildConfigurations ?? []).map { config in + ( + config.name, + .init( + name: config.name, + setting: resolvedSettings(for: config))) + }.toDictionary() + } + + func merge(_ defaultConfig: ConfigListLoader?) -> [String: Xcode.BuildSettings] { + guard let defaultConfig else { + return configs + } + + let defaults = defaultConfig.configs + return configs.map { name, current in + ( + name, + current.merged(with: defaults[name])) + }.toDictionary() + } + + static func == (lhs: ConfigListLoader, rhs: ConfigListLoader) -> Bool { + lhs.native?.uuid == rhs.native?.uuid + } + + func hash(into hasher: inout Hasher) { + hasher.combine(native?.uuid) + } + + private func resolvedSettings(for config: XCBuildConfiguration) -> [String: String] { + let fileSettings = resolvedXCConfigSettings(for: config.baseConfiguration) + let inlineSettings = config.buildSettings.mapValues(\.value) + return fileSettings.merging(inlineSettings) { _, current in current } + } + + private func resolvedXCConfigSettings( + for file: PBXFileReference?, + visited: inout Set) + -> [String: String] + { + guard let file else { return [:] } + guard let fullPath = try? file.fullPath(sourceRoot: sourceRoot.string) else { return [:] } + guard visited.insert(fullPath).inserted else { return [:] } + return resolvedXCConfigSettings(at: Path(fullPath), visited: &visited) + } + + private func resolvedXCConfigSettings(for file: PBXFileReference?) -> [String: String] { + var visited: Set = [] + return resolvedXCConfigSettings(for: file, visited: &visited) + } + + private func resolvedXCConfigSettings(at path: Path, visited: inout Set) -> [String: String] { + guard let content = try? String(contentsOfFile: path.string) else { return [:] } + + var result: [String: String] = [:] + for rawLine in content.components(separatedBy: .newlines) { + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty, !line.hasPrefix("//") else { continue } + + if + line.hasPrefix("#include"), + let start = line.firstIndex(of: "\""), + let end = line[line.index(after: start)...].firstIndex(of: "\"") + { + let includePath = String(line[line.index(after: start).. Xcode.File { + .init( + name: name, + path: relativePath ?? native.path, + fullPath: fullPath, + label: label(buildPhase: buildPhase), + fileType: fileType, + sourceTree: sourceTree, + buildPhase: buildPhase, + compilerFlags: compilerFlags, + attributes: attributes) + } + + func label(buildPhase: String?) -> String? { + if + buildPhase == BuildPhase.frameworks.rawValue, + canUsePrebuiltLabel, + !isSDKFramework, + !isSDKDylib + { + return project.transformToLabel(relativePath, .prebuilt) + } + return project.transformToLabel( + relativePath, + .source(packageName: packageName)) + } + + private var canUsePrebuiltLabel: Bool { + if let typedFileType, typedFileType.isBinaryArtifact { + return true + } + + guard let name else { return false } + return name.hasSuffix(".a") || name.hasSuffix(".dylib") + } + + private var typedFileType: KnownFileType? { + fileType.flatMap(KnownFileType.init(rawValue:)) + } +} + +// MARK: - SynchronizedFile + +struct SynchronizedFile { + enum Category { + case source + case header + case resource + case binary + case other + } + + let path: String + let fullPath: String + let compilerFlags: String? + + var name: String { + Path(path).lastComponent + } + + var fileType: String? { + typedFileType?.rawValue + } + + var category: Category { + /// A synchronized group lists the files inside a wrapper Xcode treats as one + /// resource — an asset catalog, a `.docc` bundle — and those files have + /// every extension imaginable. What owns them decides what they are. + if path.isInsideResourceWrapper { + return .resource + } + return typedFileType?.category ?? .other + } + + var file: Xcode.File { + .init( + name: name, + path: path, + fullPath: fullPath, + label: nil, + fileType: fileType, + sourceTree: "", + buildPhase: buildPhase, + compilerFlags: compilerFlags, + attributes: []) + } + + private var buildPhase: String? { + switch category { + case .source: return BuildPhase.sources.rawValue + case .header: return BuildPhase.headers.rawValue + case .resource: return BuildPhase.resources.rawValue + case .binary: return nil + case .other: return nil + } + } + + private var typedFileType: KnownFileType? { + KnownFileType(path: path) + } +} + +extension KnownFileType { + fileprivate var category: SynchronizedFile.Category { + switch self { + case .swift, .objc, .objcxx, .c, .cpp, .metal: + return .source + case .cHeader, .cppHeader: + return .header + case .xib, .storyboard, .xcassets, .strings, .stringsdict, .plist: + return .resource + case .staticLibrary, .dynamicLibrary, .dylibStub, .xcframework, .framework: + return .binary + } + } + + fileprivate var isBinaryArtifact: Bool { + switch self { + case .staticLibrary, .dynamicLibrary, .dylibStub, .xcframework, .framework: + return true + default: + return false + } + } +} + +extension PBXFileElement { + func flatten() throws -> [PBXFileElement] { + if let group = self as? PBXGroup { + return group.children.flatMap { (try? $0.flatten()) ?? [] } + } + + if let ref = self as? PBXFileReference { + return [ref] + } + + return [] + } +} + +extension Sequence { + func toDictionary() -> [K: V] where Element == (K, V) { + Dictionary(uniqueKeysWithValues: self) + } +} + +func unique(_ values: [T], key: (T) -> String) -> [T] { + var result: [T] = [] + var seen = Set() + + for value in values { + let id = key(value) + if seen.insert(id).inserted { + result.append(value) + } + } + + return result +} + +extension String { + func delete(prefix: String) -> String? { + guard hasPrefix(prefix) else { return nil } + return String(dropFirst(prefix.count)) + } +} + +extension String { + /// Directories Xcode treats as one resource, whatever they contain. + fileprivate static let resourceWrapperExtensions: Set = [ + "bundle", + "docc", + "icon", + "mlpackage", + "scnassets", + "xcassets", + "xcdatamodeld", + "xcstickers" + ] + + fileprivate var isInsideResourceWrapper: Bool { + split(separator: "/").dropLast().contains { component in + let suffix = component.split(separator: ".").last.map(String.init) ?? "" + return Self.resourceWrapperExtensions.contains(suffix) + } + } +} diff --git a/Sources/Xcode/Loader/Xcode+ProjectLoader.swift b/Sources/Xcode/Loader/Xcode+ProjectLoader.swift new file mode 100644 index 0000000..2a8c7ea --- /dev/null +++ b/Sources/Xcode/Loader/Xcode+ProjectLoader.swift @@ -0,0 +1,291 @@ +// +// Xcode+ProjectLoader.swift +// +// +// Created by Yume on 2026/3/29. +// + +import Foundation +import PathKit +import XcodeProj + +// MARK: - ProjectLoader + +final class ProjectLoader { + private let xcodeProj: XcodeProj + private let native: PBXProj + private let path: Path + let preferConfig: String? + + init(path: Path, preferConfig: String?) throws { + self.path = path + self.preferConfig = preferConfig + xcodeProj = try XcodeProj(path: path) + native = xcodeProj.pbxproj + } + + var rootProject: PBXProject? { + native.rootObject + } + + var workspacePath: Path { + path.parent() + } + + /// `PROJECT_NAME`, which build settings reference as freely as any other. + var name: String { + rootProject?.name ?? path.lastComponentWithoutExtension + } + + func model() throws -> Xcode.Project { + Xcode.Project( + name: name, + workspacePath: workspacePath.string, + projectPath: path.string, + preferConfig: preferConfig, + configs: defaultConfigList?.configs ?? [:], + packages: .init( + remote: remotePackages, + local: localPackages), + targets: targets.map(\.model)) + } + + private lazy var allFiles: [PBXFileElement] = (try? native.rootGroup()?.flatten()) ?? [] + + private lazy var targets: [TargetLoader] = native.nativeTargets.map { + TargetLoader( + native: $0, + project: self, + defaultConfigList: defaultConfigList) + } + + /// Names of every native target in the project. + lazy var targetNames: Set = Set(native.nativeTargets.map(\.name)) + + /// The project-level build configuration list every target inherits. + /// + /// Picking it by elimination (all lists minus the native targets') is both wrong + /// for projects with aggregate or legacy targets and non-deterministic, because + /// the leftovers come out of a `Set`. + private lazy var defaultConfigList: ConfigListLoader? = { + guard let list = rootProject?.buildConfigurationList else { return nil } + return ConfigListLoader(native: list, sourceRoot: workspacePath) + }() +} + +// MARK: - SwiftPM +extension ProjectLoader { + private var remotePackages: [Xcode.RemotePackage] { + (rootProject?.remotePackages ?? []).map { package in + .init( + name: package.name, + repositoryURL: package.repositoryURL, + version: package.versionRequirement?.requirementValue) + } + } + + private var localPackages: [Xcode.LocalPackage] { + let explicit = (rootProject?.localPackages ?? []).map { package in + Xcode.LocalPackage( + name: package.name, + relativePath: package.relativePath) + } + return Self.mergeLocalPackages( + explicit: explicit, + discovered: discoveredLocalPackages + synchronizedLocalPackages) + } + + private var discoveredLocalPackages: [Xcode.LocalPackage] { + allFiles + .compactMap { FileLoader(native: $0, project: self) } + .compactMap { file in + guard let relativePath = file.relativePath else { return nil } + guard let fullPath = file.fullPath else { return nil } + + let packageRoot = Path(fullPath) + guard packageRoot.isDirectory else { return nil } + guard (packageRoot + "Package.swift").exists else { return nil } + + return Xcode.LocalPackage( + name: file.name ?? packageRoot.lastComponent, + relativePath: relativePath) + } + } + + /// Local packages Xcode picks up from a synchronized group instead of an + /// explicit package reference, e.g. a `Packages/` directory holding one + /// package per subdirectory. + private var synchronizedLocalPackages: [Xcode.LocalPackage] { + native.fileSystemSynchronizedRootGroups.flatMap { group -> [Xcode.LocalPackage] in + guard let relativeRoot = group.path else { return [] } + + let root = workspacePath + relativeRoot + guard root.isDirectory else { return [] } + + if (root + "Package.swift").exists { + return [.init(name: root.lastComponent, relativePath: relativeRoot)] + } + + return (try? root.children())?.compactMap { child in + guard child.isDirectory, (child + "Package.swift").exists else { return nil } + return Xcode.LocalPackage( + name: child.lastComponent, + relativePath: "\(relativeRoot)/\(child.lastComponent)") + } ?? [] + } + } + + func packageFiles(targetName: String) -> [FileLoader] { + allFiles + .compactMap { FileLoader(native: $0, project: self) } + .filter { file in + file.packageName == targetName + } + } + + func packageName(for file: PBXFileElement) -> String? { + for target in native.nativeTargets { + if targetOwnsFile(target: target, file: file) { + return target.name + } + } + + return nil + } + + var localPackagePathByProduct: [String: String] { + var result: [String: String] = [:] + + for package in localPackages { + let packageRoot = workspacePath + package.relativePath + let manifest = packageRoot + "Package.swift" + guard let content = try? String(contentsOfFile: manifest.string) else { continue } + + for product in content.swiftPackageProductNames { + result[product] = package.relativePath + } + } + + return result + } + + enum LabelKind { + case source(packageName: String?) + case prebuilt + + var packageName: String { + switch self { + case .source(let packageName): + return packageName ?? "" + case .prebuilt: + return "Prebuilt" + } + } + } + + func transformToLabel( + _ relativePath: String?, + _ kind: LabelKind) + -> String? + { + guard let path = relativePath else { return nil } + + let targetName: String + switch kind { + case .source: + targetName = path + case .prebuilt: + targetName = Path(path).lastComponentWithoutExtension + } + + return "//\(kind.packageName):\(targetName)" + } + + static func mergeLocalPackages( + explicit: [Xcode.LocalPackage], + discovered: [Xcode.LocalPackage]) + -> [Xcode.LocalPackage] + { + var result: [Xcode.LocalPackage] = [] + var seen = Set() + + for package in explicit + discovered { + if seen.insert(package.relativePath).inserted { + result.append(package) + } + } + + return result + } +} + +extension ProjectLoader { + func explicitSynchronizedGroups(for target: PBXNativeTarget) -> [PBXFileSystemSynchronizedRootGroup] { + target.fileSystemSynchronizedGroups ?? [] + } + + func inferredSynchronizedGroups(for target: PBXNativeTarget) -> [PBXFileSystemSynchronizedRootGroup] { + let explicitGroups = explicitSynchronizedGroups(for: target) + + return native.fileSystemSynchronizedRootGroups.filter { group in + guard !explicitGroups.contains(where: { $0 === group }) else { return false } + return (group.exceptions ?? []) + .compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet } + .contains { $0.target?.name == target.name } + } + } + + private func targetOwnsFile(target: PBXNativeTarget, file: PBXFileElement) -> Bool { + if + target.buildPhases.contains(where: { phase in + phase.files?.contains(where: { $0.file === file }) == true + }) + { + return true + } + + guard let filePath = try? file.fullPath(sourceRoot: workspacePath.string) else { + return false + } + + let synchronizedGroups = explicitSynchronizedGroups(for: target) + inferredSynchronizedGroups(for: target) + + return synchronizedGroups.contains(where: { group in + guard let root = try? group.fullPath(sourceRoot: workspacePath.string) else { + return false + } + return filePath == root || filePath.hasPrefix(root + "/") + }) + } +} + +extension XCRemoteSwiftPackageReference.VersionRequirement { + fileprivate var requirementValue: Xcode.RemotePackage.Requirement { + switch self { + case .upToNextMajorVersion(let version): + return .upToNextMajorVersion(version) + case .upToNextMinorVersion(let version): + return .upToNextMinorVersion(version) + case .range(let from, let to): + return .range(from: from, to: to) + case .exact(let version): + return .exact(version) + case .branch(let branch): + return .branch(branch) + case .revision(let revision): + return .revision(revision) + } + } +} + +extension String { + fileprivate var swiftPackageProductNames: [String] { + let pattern = #"\.library\s*\(\s*name:\s*"([^"]+)""# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + let range = NSRange(startIndex..., in: self) + return regex.matches(in: self, range: range).compactMap { match in + guard let capture = Range(match.range(at: 1), in: self) else { return nil } + return String(self[capture]) + } + } +} diff --git a/Sources/Xcode/Loader/Xcode+RealPath.swift b/Sources/Xcode/Loader/Xcode+RealPath.swift new file mode 100644 index 0000000..53d00e2 --- /dev/null +++ b/Sources/Xcode/Loader/Xcode+RealPath.swift @@ -0,0 +1,27 @@ +import Foundation +import PathKit + +extension String { + /// The same file, spelled with every symlink resolved. + /// + /// Two spellings of one path do not compare equal, and the project root and + /// the files under it do not always arrive spelled the same way: `/tmp` is a + /// link to `/private/tmp`, and a file resolved through XcodeProj can keep the + /// link where the root has already lost it. A file that then fails to look + /// like it is under the root loses its path entirely, and what it generates + /// is a glob matching nothing. + /// + /// Not `resolvingSymlinksInPath()`: that one drops a leading `/private`, + /// which is the prefix `/tmp` resolves to. + var realPath: String { + guard let resolved = realpath(self, nil) else { return self } + defer { free(resolved) } + return String(cString: resolved) + } +} + +extension Path { + var realPath: Path { + Path(string.realPath) + } +} diff --git a/Sources/Xcode/Loader/Xcode+TargetLoader.swift b/Sources/Xcode/Loader/Xcode+TargetLoader.swift new file mode 100644 index 0000000..54f40c0 --- /dev/null +++ b/Sources/Xcode/Loader/Xcode+TargetLoader.swift @@ -0,0 +1,466 @@ +import Foundation +import PathKit +import XcodeProj + +// MARK: - TargetLoader + +struct TargetLoader { + let native: PBXNativeTarget + unowned let project: ProjectLoader + let preferConfig: String? + let configList: ConfigListLoader + let mergedConfig: [String: Xcode.BuildSettings] + + init(native: PBXNativeTarget, project: ProjectLoader, defaultConfigList: ConfigListLoader?) { + self.native = native + self.project = project + preferConfig = project.preferConfig + configList = ConfigListLoader(native: native.buildConfigurationList, sourceRoot: project.workspacePath) + /// Xcode's built-in settings never appear in the project file, but build + /// settings reference them freely (`INFOPLIST_FILE = $(SRCROOT)/...`). + let workspace = project.workspacePath.string + mergedConfig = configList.merge(defaultConfigList).mapValues { settings in + settings + .with(overrides: [ + "TARGET_NAME": native.name, + "PROJECT_NAME": project.name, + "SRCROOT": workspace, + "SOURCE_ROOT": workspace, + "PROJECT_DIR": workspace, + ]) + /// What the toolchain answers for, and the configuration being + /// built — defaults, because a project that states one of them + /// itself means it: iina writes `CONFIGURATION` into an xcconfig. + /// + /// The platform is resolved rather than read: `SDKROOT` is + /// optional, and `auto` names no SDK at all. + .with(defaults: Toolchain + .settings(sdk: settings.platform.resolvedSDK?.rawValue) + .merging(["CONFIGURATION": settings.name]) { _, new in new }) + } + } + + var name: String { native.name } + + var model: Xcode.Target { + let buildPhases = native.buildPhases.map(Xcode.BuildPhase.init) + let synchronizedFiles = synchronizedGroupFiles + + let sourceFiles = unique( + fileModels(from: sourceBuildFiles, buildPhase: .sources) + + synchronizedFiles.filter { file in + file.category == .source + }.map(\.file)) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } + let headerFiles = unique( + fileModels(from: headerBuildFiles, buildPhase: .headers) + + packageHeaders + + synchronizedFiles.filter { file in + file.category == .header + }.map(\.file)) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } + let resourceFiles = unique( + fileModels(from: resourceBuildFiles, buildPhase: .resources) + + synchronizedFiles.filter { file in + file.category == .resource + }.map(\.file)) { "\($0.path ?? "")|\($0.buildPhase ?? "")" } + let frameworkFiles = fileModels(from: frameworkBuildFiles, buildPhase: .frameworks) + let copyFiles = fileModels(from: copyBuildFiles, buildPhase: .copyFiles) + + let knownPaths = Set( + (sourceFiles + headerFiles + resourceFiles + frameworkFiles + copyFiles) + .compactMap(\.path)) + + let otherFiles = project.packageFiles(targetName: name) + .filter { file in + guard let path = file.relativePath else { return false } + return !knownPaths.contains(path) + } + .map { $0.file(buildPhase: nil, compilerFlags: nil, attributes: []) } + + synchronizedFiles.filter { file in + file.category == .other && !knownPaths.contains(file.file.path ?? "") + }.map(\.file) + + return Xcode.Target( + name: name, + productName: native.productName, + productType: native.productType?.rawValue, + preferConfig: preferConfig, + configs: mergedConfig, + metadata: metadata, + buildPhases: buildPhases, + files: .init( + sources: sourceFiles, + headers: headerFiles, + resources: resourceFiles, + frameworks: frameworkFiles, + copyFiles: copyFiles, + others: unique(otherFiles) { "\($0.path ?? "")|\($0.buildPhase ?? "")" }), + dependencies: dependencies) + } + + private var metadata: Xcode.TargetMetadata { + let settings = selectedConfig ?? .init(name: "", setting: [:]) + + return .init( + bundleID: settings.metadata.bundleID, + moduleName: settings.metadata.moduleName ?? settings.metadata.productName, + infoPlist: settings.plist.infoPlist, + entitlements: settings.metadata.codeSignEntitlements, + deploymentTargets: settings.platform.deploymentTargets, + codeSign: .init( + developmentTeam: settings.metadata.developmentTeam, + codeSignStyle: settings.metadata.codeSignStyle, + codeSignIdentity: settings.metadata.codeSignIdentity)) + } + + private var dependencies: Xcode.Dependencies { + let declaredDependencies = native.dependencies.compactMap { dependency in + dependency.target?.name ?? dependency.name + } + + /// A target can link a sibling target's framework through the Frameworks + /// phase without declaring a target dependency; Xcode resolves it implicitly. + let implicitDependencies = frameworkBuildFiles.compactMap { buildFile -> String? in + guard let file = buildFile.file else { return nil } + let wrapped = FileLoader(native: file, project: project) + guard let identity = wrapped.frameworkIdentity else { return nil } + guard identity != name, project.targetNames.contains(identity) else { return nil } + return identity + } + + let targetDependencies = declaredDependencies + implicitDependencies + let targetDependencyIdentities = Set(targetDependencies) + + let frameworks = frameworkBuildFiles.compactMap { buildFile -> String? in + guard let file = buildFile.file else { return nil } + let wrapped = FileLoader(native: file, project: project) + guard !wrapped.isSDKFramework, !wrapped.isSDKDylib else { return nil } + + if let identity = wrapped.frameworkIdentity, targetDependencyIdentities.contains(identity) { + return nil + } + + if + let label = wrapped.label(buildPhase: BuildPhase.frameworks.rawValue), + label.hasPrefix("//Prebuilt:") + { + /// A framework that only exists after a Carthage/CocoaPods/script + /// bootstrap cannot be imported, and referencing it anyway leaves the + /// generated workspace unloadable. + guard wrapped.existsOnDisk else { return nil } + return label + } + + return wrapped.name + } + + let sdkFrameworks = frameworkBuildFiles.compactMap { buildFile -> String? in + guard let file = buildFile.file else { return nil } + let wrapped = FileLoader(native: file, project: project) + guard wrapped.isSDKFramework else { return nil } + guard !(buildFile.attributes ?? []).contains("Weak") else { return nil } + return wrapped.sdkFrameworkName + } + + let weakSDKFrameworks = frameworkBuildFiles.compactMap { buildFile -> String? in + guard let file = buildFile.file else { return nil } + let wrapped = FileLoader(native: file, project: project) + guard wrapped.isSDKFramework else { return nil } + guard (buildFile.attributes ?? []).contains("Weak") else { return nil } + return wrapped.sdkFrameworkName + } + + let sdkDylibs = frameworkBuildFiles.compactMap { buildFile -> String? in + guard let file = buildFile.file else { return nil } + let wrapped = FileLoader(native: file, project: project) + /// Only a dylib the SDK ships is linked by name; one the project carries + /// is imported by path, like any other prebuilt binary. + guard wrapped.isSDKDylib else { return nil } + return wrapped.sdkDylibName ?? wrapped.name.flatMap { Path($0).lastComponentWithoutExtension } + } + + /// Xcode references system frameworks by absolute path; only their directory + /// matters for linking, and anything outside the default + /// `/System/Library/Frameworks` has to be handed to the linker explicitly. + let sdkFrameworkSearchPaths = frameworkBuildFiles.compactMap { buildFile -> String? in + guard let file = buildFile.file else { return nil } + let wrapped = FileLoader(native: file, project: project) + guard wrapped.isSDKFramework, let fullPath = wrapped.fullPath, fullPath.hasPrefix("/") else { + return nil + } + let directory = Path(fullPath).parent().string + guard directory != "/System/Library/Frameworks" else { return nil } + return directory + } + + /// Xcode records a linked package product either on the target or on the + /// build file in the Frameworks phase, depending on how it was added. + let excluded = filteredProductNames + let productDependencies = ((native.packageProductDependencies ?? []) + frameworkBuildFiles.compactMap { buildFile in + buildFile.product + }).filter { product in + !excluded.contains(product.productName) + } + + let packageProducts = unique(productDependencies) { $0.productName }.map { dependency in + Xcode.PackageProductDependency( + productName: dependency.productName, + package: dependency.package?.repositoryURL, + packagePath: project.localPackagePathByProduct[dependency.productName]) + } + + return .init( + targets: Set(targetDependencies).sorted(), + packageProducts: unique(packageProducts) { "\($0.productName)|\($0.package ?? "")" }, + frameworks: Set(frameworks.compactMap { $0 }).sorted(), + sdkDylibs: Set(sdkDylibs.compactMap { $0 }).sorted(), + sdkFrameworks: Set(sdkFrameworks.compactMap { $0 }).sorted(), + sdkFrameworkSearchPaths: Set(sdkFrameworkSearchPaths).sorted(), + weakSDKFrameworks: Set(weakSDKFrameworks.compactMap { $0 }).sorted()) + } + + private var selectedConfig: Xcode.BuildSettings? { + if let prefer = project.preferConfig, let hit = mergedConfig[prefer] { + return hit + } + return mergedConfig + .sorted { $0.key < $1.key } + .map(\.value) + .first + } + + private var packageHeaders: [Xcode.File] { + project.packageFiles(targetName: name) + .filter { file in + guard let type = file.fileType else { return false } + return type == "sourcecode.c.h" || type == "sourcecode.cpp.h" + } + .map { $0.file(buildPhase: BuildPhase.headers.rawValue, compilerFlags: nil, attributes: []) } + } + + private var sourceBuildFiles: [PBXBuildFile] { + ((try? native.sourcesBuildPhase()?.files) ?? []).filter(links) + } + + private var headerBuildFiles: [PBXBuildFile] { + native.buildPhases + .compactMap { $0 as? PBXHeadersBuildPhase } + .compactMap(\.files) + .flatMap { $0 } + .filter(links) + } + + private var resourceBuildFiles: [PBXBuildFile] { + ((try? native.resourcesBuildPhase()?.files) ?? []).filter(links) + } + + private var frameworkBuildFiles: [PBXBuildFile] { + allFrameworkBuildFiles.filter(links) + } + + private var allFrameworkBuildFiles: [PBXBuildFile] { + (try? native.frameworksBuildPhase()?.files) ?? [] + } + + /// Package products the target links only on another platform. + /// + /// The filter is on the build file, while the product is also listed on the + /// target itself, so the target's own list has to be read through the filter. + private var filteredProductNames: Set { + let linked = Set(frameworkBuildFiles.compactMap { $0.product?.productName }) + let filtered = allFrameworkBuildFiles + .filter { !links($0) } + .compactMap { $0.product?.productName } + + return Set(filtered).subtracting(linked) + } + + /// Whether the target links a build file at all. + /// + /// Xcode can restrict a linked framework or package product to some platforms + /// — UTM links a visionOS keyboard only when building for visionOS — and the + /// entry is invisible to every other platform, sources and all. + private func links(_ buildFile: PBXBuildFile) -> Bool { + let filters = (buildFile.platformFilters ?? []) + [buildFile.platformFilter].compactMap { $0 } + guard !filters.isEmpty else { return true } + guard let platform = platformFilterName else { return true } + + return filters.contains { filter in + filter == platform || filter.hasPrefix("\(platform)-") + } + } + + /// The platform as a build file's filter names it. + private var platformFilterName: String? { + switch selectedConfig?.platform.resolvedSDK { + case .iOS: + return "ios" + case .macOS: + return "macos" + case .tvOS: + return "tvos" + case .watchOS: + return "watchos" + case .driverKit: + return "driverkit" + case .auto, .none: + return nil + } + } + + private var copyBuildFiles: [PBXBuildFile] { + native.buildPhases + .compactMap { $0 as? PBXCopyFilesBuildPhase } + .compactMap(\.files) + .flatMap { $0 } + .filter(links) + } + + private var synchronizedGroupFiles: [SynchronizedFile] { + let explicit = project.explicitSynchronizedGroups(for: native).flatMap { group in + synchronizedFiles(in: group, membershipMode: .excludeListed) + } + let inferred = project.inferredSynchronizedGroups(for: native).flatMap { group in + synchronizedFiles(in: group, membershipMode: .includeListed) + } + + return unique(explicit + inferred) { "\($0.path)|\($0.fullPath)|\($0.compilerFlags ?? "")" } + } + + private func synchronizedFiles( + in group: PBXFileSystemSynchronizedRootGroup, + membershipMode: SynchronizedMembershipMode) + -> [SynchronizedFile] + { + guard let relativeRoot = group.path else { return [] } + let root = project.workspacePath + relativeRoot + guard root.exists else { return [] } + + let membershipPaths = synchronizedMembershipPaths(group) + let compilerFlags = synchronizedCompilerFlags(group) + + return (try? root.recursiveChildren())? + .filter(\.isFile) + .compactMap { file in + let relative = file.string.realPath + .delete(prefix: project.workspacePath.string.realPath + "/") + guard let relative else { return nil } + + let pathInGroup = relative.delete(prefix: relativeRoot + "/") ?? "" + switch membershipMode { + case .excludeListed: + guard !membershipPaths.contains(pathInGroup), !membershipPaths.contains(relative) else { + return nil + } + case .includeListed: + guard membershipPaths.contains(pathInGroup) || membershipPaths.contains(relative) else { + return nil + } + } + + return SynchronizedFile( + path: relative, + fullPath: file.string, + compilerFlags: compilerFlags[pathInGroup] ?? compilerFlags[relative]) + } ?? [] + } + + private func synchronizedMembershipPaths(_ group: PBXFileSystemSynchronizedRootGroup) -> Set { + let buildExceptions = (group.exceptions ?? []).compactMap { + $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet + }.filter { exception in + exception.target?.name == name + } + + let membershipExceptions = buildExceptions + .compactMap(\.membershipExceptions) + .flatMap { $0 } + + return Set(membershipExceptions) + } + + private func synchronizedCompilerFlags(_ group: PBXFileSystemSynchronizedRootGroup) -> [String: String] { + let buildExceptions = (group.exceptions ?? []).compactMap { + $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet + }.filter { exception in + exception.target?.name == name + } + + return buildExceptions + .compactMap(\.additionalCompilerFlagsByRelativePath) + .reduce(into: [:]) { result, next in + result.merge(next) { first, _ in first } + } + } + + private enum SynchronizedMembershipMode { + case excludeListed + case includeListed + } + + private func fileModels(from buildFiles: [PBXBuildFile], buildPhase: BuildPhase) -> [Xcode.File] { + buildFiles.flatMap { buildFile -> [Xcode.File] in + guard let file = buildFile.file else { return [] } + + /// A localized resource is one build file referencing a variant group; + /// what Xcode copies into the bundle are its children, one `.lproj` + /// directory per language. + guard let variant = file as? PBXVariantGroup else { + return [ + FileLoader(native: file, project: project).file( + buildPhase: buildPhase.rawValue, + compilerFlags: buildFile.compilerFlags, + attributes: buildFile.attributes ?? []), + ] + } + + let root = project.workspacePath.string + let base = try? variant.parent?.fullPath(sourceRoot: root) + + return variant.children.compactMap { child in + guard let path = child.path else { return nil } + return FileLoader( + native: child, + project: project, + pathOverride: base.map { "\($0)/\(path)" }) + .file( + buildPhase: buildPhase.rawValue, + compilerFlags: buildFile.compilerFlags, + attributes: buildFile.attributes ?? []) + } + } + } +} + +extension Xcode.BuildPhase { + fileprivate init(phase: PBXBuildPhase) { + let destination: Xcode.CopyFilesDestination? + if let copyPhase = phase as? PBXCopyFilesBuildPhase { + destination = .init( + path: copyPhase.dstPath, + subfolder: copyPhase.dstSubfolder?.rawValue, + subfolderSpec: copyPhase.dstSubfolderSpec?.rawValue) + } else { + destination = nil + } + + self.init( + type: phase.buildPhase.rawValue, + name: phase.name(), + files: (phase.files ?? []).compactMap { buildFile in + Xcode.BuildPhaseFile( + name: (buildFile.file as? PBXFileReference)?.name ?? + (buildFile.file as? PBXFileReference)?.path ?? + buildFile.product?.productName, + path: buildFile.file?.path, + fileType: (buildFile.file as? PBXFileReference)?.lastKnownFileType, + compilerFlags: buildFile.compilerFlags, + attributes: buildFile.attributes ?? []) + }, + inputPaths: (phase as? PBXShellScriptBuildPhase)?.inputPaths ?? [], + outputPaths: (phase as? PBXShellScriptBuildPhase)?.outputPaths ?? [], + inputFileListPaths: phase.inputFileListPaths ?? [], + outputFileListPaths: phase.outputFileListPaths ?? [], + shellScript: (phase as? PBXShellScriptBuildPhase)?.shellScript, + destination: destination) + } +} diff --git a/Sources/Xcode/Loader/Xcode+Toolchain.swift b/Sources/Xcode/Loader/Xcode+Toolchain.swift new file mode 100644 index 0000000..09fbd1c --- /dev/null +++ b/Sources/Xcode/Loader/Xcode+Toolchain.swift @@ -0,0 +1,127 @@ +import Foundation + +// MARK: - Toolchain + +/// The build settings Xcode fills in from the installed toolchain rather than +/// from the project. +/// +/// A project references them as freely as its own: iina writes +/// `$(SDK_VERSION)` and `$(XCODE_VERSION_ACTUAL)` into its `Info.plist`, and +/// neither appears anywhere in the project file — Xcode is the one that knows +/// them, so nobody can be asked to type them in. +/// +/// `xcodebuild -showBuildSettings` knows every one of them, but it resolves the +/// package graph and takes some ten seconds per target, so the values are read +/// from the toolchain directly: two commands for a whole run, both of which +/// answer for every platform at once. +enum Toolchain { + /// What Xcode would set for a target built against `sdk`, which is the + /// project's `SDKROOT`. + static func settings(sdk: String?) -> [String: String] { + guard let platform = canonical(sdk), let sdk = sdks[platform] else { return xcode } + + return xcode.merging([ + "PLATFORM_NAME": platform, + "SDK_NAME": sdk.name, + "SDK_VERSION": sdk.version, + ]) { _, new in new } + } + + // MARK: Private + + private struct SDK: Decodable { + let canonicalName: String + let sdkVersion: String + let platform: String + } + + /// `SDKROOT` is a platform name (`macosx`), a canonical SDK name + /// (`macosx26.0`), `auto`, or a path. Only a name identifies a platform + /// without building the target first. + private static func canonical(_ sdk: String?) -> String? { + guard + let sdk = sdk?.lowercased(), + !sdk.isEmpty, + sdk != "auto", + !sdk.contains("/") + else { + return nil + } + + return sdks[sdk] != nil ? sdk : sdks.first { _, value in value.name == sdk }?.key + } + + /// Every installed SDK, by platform: one `xcodebuild -showsdks` answers for + /// all of them, and the newest of a platform is the one Xcode builds with. + private static let sdks: [String: (name: String, version: String)] = { + guard + let output = run("xcodebuild", ["-showsdks", "-json"]), + let sdks = try? JSONDecoder().decode([SDK].self, from: Data(output.utf8)) + else { + return [:] + } + + var result: [String: (name: String, version: String)] = [:] + for sdk in sdks { + let current = result[sdk.platform] + if let current, current.version.compare(sdk.sdkVersion, options: .numeric) != .orderedAscending { + continue + } + result[sdk.platform] = (name: sdk.canonicalName, version: sdk.sdkVersion) + } + + return result + }() + + /// How Xcode spells its own version: a four digit number, so 27.0 is `2700` + /// and 14.3.1 is `1431`. `MAJOR` keeps only the major, `MINOR` drops the + /// patch. + static func settings(xcodeVersion version: String) -> [String: String] { + let components = version.split(separator: ".").compactMap { Int($0) } + guard let major = components.first else { return [:] } + let minor = components.count > 1 ? components[1] : 0 + let patch = components.count > 2 ? components[2] : 0 + + return [ + "XCODE_VERSION_ACTUAL": "\(major * 100 + minor * 10 + patch)", + "XCODE_VERSION_MAJOR": "\(major * 100)", + "XCODE_VERSION_MINOR": "\(major * 100 + minor * 10)", + ] + } + + /// `xcodebuild -version` says `Xcode 27.0` on its first line. + private static let xcode: [String: String] = { + guard + let output = run("xcodebuild", ["-version"]), + let version = output.split(separator: "\n").first?.split(separator: " ").last + else { + return [:] + } + + return settings(xcodeVersion: String(version)) + }() + + /// A toolchain that cannot be asked leaves the settings unset, which is what + /// they already were. + private static func run(_ executable: String, _ arguments: [String]) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/\(executable)") + process.arguments = arguments + + let output = Pipe() + process.standardOutput = output + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + return nil + } + + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + + return String(data: data, encoding: .utf8) + } +} diff --git a/Sources/Xcode/Model/Config/Xcode+BuildSettings+AssetCatalog.swift b/Sources/Xcode/Model/Config/Xcode+BuildSettings+AssetCatalog.swift new file mode 100644 index 0000000..a66f879 --- /dev/null +++ b/Sources/Xcode/Model/Config/Xcode+BuildSettings+AssetCatalog.swift @@ -0,0 +1,27 @@ +import Foundation + +extension Xcode.BuildSettings { + public var assetCatalog: AssetCatalog { + .init(settings: self) + } + + public struct AssetCatalog { + fileprivate let settings: Xcode.BuildSettings + + public var appIconName: String? { + settings["ASSETCATALOG_COMPILER_APPICON_NAME"] + } + + public var accentColorName: String? { + settings["ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME"] + } + + /// `ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS` + /// + /// Xcode 15+ generates `ImageResource`/`ColorResource` members from the + /// catalogs and compiles them into the target. + public var generatesSwiftSymbols: Bool { + settings["ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS"] == "YES" + } + } +} diff --git a/Sources/Xcode/Model/Config/Xcode+BuildSettings+Metadata.swift b/Sources/Xcode/Model/Config/Xcode+BuildSettings+Metadata.swift new file mode 100644 index 0000000..ac2eb1a --- /dev/null +++ b/Sources/Xcode/Model/Config/Xcode+BuildSettings+Metadata.swift @@ -0,0 +1,39 @@ +import Foundation + +extension Xcode.BuildSettings { + public var metadata: Metadata { + .init(settings: self) + } + + public struct Metadata { + fileprivate let settings: Xcode.BuildSettings + + public var bundleID: String? { + settings["PRODUCT_BUNDLE_IDENTIFIER"] + } + + public var moduleName: String? { + settings["PRODUCT_MODULE_NAME"] + } + + public var productName: String? { + settings["PRODUCT_NAME"] + } + + public var developmentTeam: String? { + settings["DEVELOPMENT_TEAM"] + } + + public var codeSignStyle: String? { + settings["CODE_SIGN_STYLE"] + } + + public var codeSignIdentity: String? { + settings["CODE_SIGN_IDENTITY"] + } + + public var codeSignEntitlements: String? { + settings["CODE_SIGN_ENTITLEMENTS"] + } + } +} diff --git a/Sources/Xcode/Model/Config/Xcode+BuildSettings+PList.swift b/Sources/Xcode/Model/Config/Xcode+BuildSettings+PList.swift new file mode 100644 index 0000000..68d2c0b --- /dev/null +++ b/Sources/Xcode/Model/Config/Xcode+BuildSettings+PList.swift @@ -0,0 +1,165 @@ +import Foundation + +private let plistPrefix = "INFOPLIST_KEY_" + +extension Xcode.BuildSettings { + // MARK: Info.plist + + public var plist: Plist { + .init(settings: self) + } + + public var generatedPlist: GeneratedPlist { + .init(settings: self) + } + + public struct Plist { + fileprivate let settings: Xcode.BuildSettings + + /// "ABCDEF/Info.plist" + public var infoPlist: String? { + settings["INFOPLIST_FILE"] + } + + /// "LaunchScreen" + public var launch: String? { + plistValue("UILaunchStoryboardName") + } + + /// "Main" + public var storyboard: String? { + plistValue("UIMainStoryboardFile") + } + + /// INFOPLIST_KEY_ + /// INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad + public var keys: [String] { + settings.keys.filter { key in + key.hasPrefix(plistPrefix) + } + .sorted() + } + + private func plistValue(_ key: String) -> String? { + settings["\(plistPrefix)\(key)"] + } + } + + public struct GeneratedPlist { + fileprivate let settings: Xcode.BuildSettings + + /// "YES" + public var enabled: Bool { + settings["GENERATE_INFOPLIST_FILE"] == "YES" + } + + /// CFBundleVersion - CURRENT_PROJECT_VERSION + /// + /// Default Info.plist value: + /// CFBundleVersion -> $(CURRENT_PROJECT_VERSION) + public var currentProjectVersion: String? { + settings["CURRENT_PROJECT_VERSION"] + } + + /// CFBundleShortVersionString - MARKETING_VERSION + /// + /// Default Info.plist value: + /// CFBundleShortVersionString -> $(MARKETING_VERSION) + public var marketingVersion: String? { + settings["MARKETING_VERSION"] + } + + /// Default Info.plist value: + /// CFBundleName -> $(PRODUCT_NAME) + /// CFBundleIdentifier -> $(PRODUCT_BUNDLE_IDENTIFIER) + /// CFBundleExecutable -> $(EXECUTABLE_NAME) + /// CFBundlePackageType -> $(PRODUCT_BUNDLE_PACKAGE_TYPE) + /// CFBundleDevelopmentRegion -> $(DEVELOPMENT_LANGUAGE) + public var defaultInfoPlistKeyNotes: [String] { + [ + "CFBundleName -> $(PRODUCT_NAME)", + "CFBundleIdentifier -> $(PRODUCT_BUNDLE_IDENTIFIER)", + "CFBundleExecutable -> $(EXECUTABLE_NAME)", + "CFBundlePackageType -> $(PRODUCT_BUNDLE_PACKAGE_TYPE)", + "CFBundleDevelopmentRegion -> $(DEVELOPMENT_LANGUAGE)", + "CFBundleVersion -> $(CURRENT_PROJECT_VERSION)", + "CFBundleShortVersionString -> $(MARKETING_VERSION)", + ] + } + + /// GENERATED_INFOPLIST_FILE + /// + /// Render a minimal subset of INFOPLIST_KEY_* settings into plist XML + /// fragments so generated Info.plist files preserve common Xcode + /// build-setting customizations. + public var entries: [String] { + guard enabled else { return [] } + + return settings.plist.keys.sorted().flatMap { key -> [String] in + guard let value = settings[key] else { return [] } + + switch plistDecision(for: key) { + case .string: + return [plistKey(key), plistString(value)] + case .stringArray: + return [plistKey(key), plistStringArray(value)] + case .bool: + return [plistKey(key), plistBool(value)] + case .unknown: + return [] + } + } + } + + private func plistDecision(for key: String) -> PlistDecision { + switch key { + case "INFOPLIST_KEY_UIMainStoryboardFile", + "INFOPLIST_KEY_UILaunchStoryboardName": + return .string + case "INFOPLIST_KEY_UISupportedInterfaceOrientations": + return .stringArray + case "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents": + return .bool + default: + return .unknown + } + } + + private func plistKey(_ key: String) -> String { + let newKey = key.delete(prefix: plistPrefix) ?? key + return "\(newKey)" + } + + private func plistBool(_ value: String) -> String { + value == "YES" ? "" : "" + } + + private func plistString(_ value: String) -> String { + "\(value)" + } + + private func plistStringArray(_ value: String) -> String { + let strings = value + .split(separator: " ") + .map(String.init) + .map(plistString) + .map { " \($0)" } + .joined(separator: "\n") + + return """ + + \(strings) + + """ + } + } +} + +// MARK: - PlistDecision + +private enum PlistDecision { + case string + case stringArray + case bool + case unknown +} diff --git a/Sources/Xcode/Model/Config/Xcode+BuildSettings+Platform.swift b/Sources/Xcode/Model/Config/Xcode+BuildSettings+Platform.swift new file mode 100644 index 0000000..dfaf43f --- /dev/null +++ b/Sources/Xcode/Model/Config/Xcode+BuildSettings+Platform.swift @@ -0,0 +1,104 @@ +import Foundation + +// MARK: - SDK + +public enum SDK: String, Hashable { + case iOS = "iphoneos" + case macOS = "macosx" + case tvOS = "appletvos" + case watchOS = "watchos" + case driverKit = "driverkit" + case auto +} + +extension Xcode.BuildSettings { + public var platform: Platform { + .init(settings: self) + } + + public struct Platform { + fileprivate let settings: Xcode.BuildSettings + + public var sdk: SDK? { + SDK(rawValue: settings["SDKROOT"] ?? "") + } + + /// `SUPPORTED_PLATFORMS`, which decides the platform when `SDKROOT = auto`. + public var supportedPlatforms: [SDK] { + (settings["SUPPORTED_PLATFORMS"] ?? "") + .split(separator: " ") + .compactMap { SDK(rawValue: String($0)) } + } + + public var iOS: String? { + settings["IPHONEOS_DEPLOYMENT_TARGET"] + } + + public var macOS: String? { + settings["MACOSX_DEPLOYMENT_TARGET"] + } + + public var tvOS: String? { + settings["TVOS_DEPLOYMENT_TARGET"] + } + + public var watchOS: String? { + settings["WATCHOS_DEPLOYMENT_TARGET"] + } + + public var driverKit: String? { + settings["DRIVERKIT_DEPLOYMENT_TARGET"] + } + + public var deploymentTargets: [String: String] { + [ + "iOS": iOS, + "macOS": macOS, + "tvOS": tvOS, + "watchOS": watchOS, + "driverKit": driverKit, + ].compactMapValues { $0 } + } + + /// The platform the settings build for. + /// + /// `SDKROOT` is optional in a project file, and `auto` means the target is + /// multiplatform: `SUPPORTED_PLATFORMS` narrows it down, then the device + /// family, then whichever deployment target is set. + public var resolvedSDK: SDK? { + if let sdk, sdk != .auto { + return sdk + } + if let platform = supportedPlatforms.first(where: { $0 != .auto }) { + return platform + } + if deviceFamily.contains(.iphone) { + return .iOS + } + if iOS != nil { + return .iOS + } + if macOS != nil { + return .macOS + } + if tvOS != nil { + return .tvOS + } + if watchOS != nil { + return .watchOS + } + + return sdk + } + + public var deviceFamily: [Xcode.DeviceFamily] { + Xcode.DeviceFamily.parse(settings["TARGETED_DEVICE_FAMILY"]) + } + + public var appleFamiliesLiteral: String? { + let families = deviceFamily.map(\.code) + guard !families.isEmpty else { return nil } + return "[" + families.map { #""\#($0)""# }.joined(separator: ", ") + "]" + } + } +} diff --git a/Sources/Xcode/Model/Config/Xcode+BuildSettings.swift b/Sources/Xcode/Model/Config/Xcode+BuildSettings.swift new file mode 100644 index 0000000..79b9d3d --- /dev/null +++ b/Sources/Xcode/Model/Config/Xcode+BuildSettings.swift @@ -0,0 +1,248 @@ +import Foundation +import XcodeProj + +extension BuildSetting { + var value: String { + switch self { + case .string(let value): + return value + case .array(let value): + return value.joined(separator: " ") + } + } +} + +// MARK: - Xcode.BuildSettings + +extension Xcode { + public struct BuildSettings: Encodable { + public let name: String + private let setting: [String: String] + + public init(name: String, setting: [String: String]) { + self.name = name + self.setting = setting + } + + init(_ config: XCBuildConfiguration) { + self.init( + name: config.name, + setting: config.buildSettings.mapValues(\.value)) + } + + func merged(with defaults: BuildSettings?) -> BuildSettings { + guard let defaults else { + return self + } + + return .init( + name: name, + setting: setting.merging(defaults.setting) { current, _ in + current + }) + } + + func with(overrides: [String: String]) -> BuildSettings { + .init( + name: name, + setting: setting.merging(overrides) { _, new in + new + }) + } + + /// Values for the settings this configuration does not state itself. + func with(defaults: [String: String]) -> BuildSettings { + .init( + name: name, + setting: setting.merging(defaults) { current, _ in + current + }) + } + + public subscript(key: String) -> String? { + resolved(setting[key], visited: [key]) + } + + var keys: [String] { + Array(setting.keys) + } + } +} + +extension Xcode.BuildSettings { + public var swiftVersion: String? { self["SWIFT_VERSION"] } + + /// `SWIFT_DEFAULT_ACTOR_ISOLATION`: the module-wide default Xcode compiles with + /// (SE-0466). Code written against `MainActor` by default does not compile + /// without it. + public var swiftDefaultActorIsolation: String? { + guard let value = self["SWIFT_DEFAULT_ACTOR_ISOLATION"], !value.isEmpty else { return nil } + return value + } + public var swiftDefine: String? { self["OTHER_SWIFT_FLAGS"] } + + /// Everything Swift compiles with `-D`: the conditions Xcode dedicates a + /// setting to, plus any `-D` smuggled through `OTHER_SWIFT_FLAGS`. + /// + /// `SWIFT_ACTIVE_COMPILATION_CONDITIONS` is how a project spells `#if FEATURE` + /// for Swift — UTM decides which SPICE module to import with it. + public var swiftDefines: [String] { + let conditions = (self["SWIFT_ACTIVE_COMPILATION_CONDITIONS"] ?? "") + .split(separator: " ") + .map(String.init) + .filter { !$0.isEmpty && $0 != "$(inherited)" } + + var flagged: [String] = [] + var isPreviousDefine = false + for flag in (swiftDefine ?? "").split(separator: " ").map(String.init) { + if flag == "-D" { + isPreviousDefine = true + } else if isPreviousDefine { + /// `-D ABC` + flagged.append(flag) + isPreviousDefine = false + } else if flag.hasPrefix("-D") { + /// `-DABC` + flagged.append(String(flag.dropFirst(2))) + } + } + + var result: [String] = [] + for define in conditions + flagged where !result.contains(define) { + /// `swiftc` rejects anything that is not an identifier, and a project + /// routinely leaves a build setting reference in here — iina spells one + /// condition `$AVAILABLE_$(SDK_VERSION_MAJOR)`. + guard define.range(of: #"^[A-Za-z_][A-Za-z0-9_]*$"#, options: .regularExpression) != nil else { + continue + } + result.append(define) + } + return result + } + public var bridgingHeader: String? { self["SWIFT_OBJC_BRIDGING_HEADER"] } + + /// `GCC_PREFIX_HEADER`: a header Xcode force-includes into every C-family + /// compile of the target, which is how a source file gets away without + /// importing the framework it uses. + public var prefixHeader: String? { + guard let header = self["GCC_PREFIX_HEADER"]?.unquoted, !header.isEmpty else { return nil } + return header + } + + /// `HEADER_SEARCH_PATHS` plus `USER_HEADER_SEARCH_PATHS`, without Xcode's + /// `$(inherited)` marker. + public var headerSearchPaths: [String] { + ["HEADER_SEARCH_PATHS", "USER_HEADER_SEARCH_PATHS"] + .compactMap { self[$0] } + .flatMap { value in + value.split(separator: " ").map(String.init) + } + .map { path in + path.trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + } + .filter { path in + !path.isEmpty && path != "$(inherited)" + } + } + + /// `OTHER_LDFLAGS`, without Xcode's `$(inherited)` marker. + public var otherLinkerFlags: [String] { + (self["OTHER_LDFLAGS"] ?? "") + .split(separator: " ") + .map { flag in + String(flag).unquoted + } + .filter { !$0.isEmpty && $0 != "$(inherited)" } + } + + /// `GCC_PREPROCESSOR_DEFINITIONS`, without Xcode's `$(inherited)` marker. + /// + /// Xcode passes each entry through a shell, so a value is often quoted + /// (`ID='@"com.example"'`); Bazel hands `defines` to the compiler directly and + /// the quotes would end up inside the macro. + public var preprocessorDefinitions: [String] { + (self["GCC_PREPROCESSOR_DEFINITIONS"] ?? "") + .split(separator: " ") + .map(String.init) + .filter { !$0.isEmpty && $0 != "$(inherited)" } + .map { definition in + guard let separator = definition.firstIndex(of: "=") else { return definition } + let key = definition[.. 1 { + return String(dropFirst().dropLast()) + } + return String(self) + } +} + +extension Xcode.BuildSettings { + /// Xcode spells a reference `$(NAME)` or `${NAME}` and allows a modifier: + /// `$(PRODUCT_NAME:rfc1034identifier)`. + private static let referencePattern = #"\$[({]([A-Za-z0-9_]+)(?::([A-Za-z0-9_]+))?[)}]"# + + private func resolved(_ value: String?, visited: Set) -> String? { + guard let value else { return nil } + + guard let regex = try? NSRegularExpression(pattern: Self.referencePattern) else { return value } + + let matches = regex.matches( + in: value, + range: NSRange(value.startIndex..., in: value)) + guard !matches.isEmpty else { return value } + + var result = value + for match in matches.reversed() { + guard + let wholeRange = Range(match.range(at: 0), in: value), + let keyRange = Range(match.range(at: 1), in: value) + else { + continue + } + + let key = String(value[keyRange]) + guard !visited.contains(key), let replacement = resolved(setting[key], visited: visited.union([key])) else { + continue + } + + let modifier = Range(match.range(at: 2), in: value).map { String(value[$0]) } + result.replaceSubrange(wholeRange, with: Self.apply(modifier, to: replacement)) + } + + return result + } + + private static func apply(_ modifier: String?, to value: String) -> String { + switch modifier { + case "rfc1034identifier": + return value.map { character in + character.isLetter || character.isNumber || character == "." || character == "-" + ? String(character) + : "-" + }.joined() + case "identifier", "c99extidentifier": + return value.map { character in + character.isLetter || character.isNumber ? String(character) : "_" + }.joined() + case "lower": + return value.lowercased() + case "upper": + return value.uppercased() + default: + return value + } + } +} diff --git a/Sources/Xcode/Model/Config/Xcode+DeviceFamily.swift b/Sources/Xcode/Model/Config/Xcode+DeviceFamily.swift new file mode 100644 index 0000000..27ac084 --- /dev/null +++ b/Sources/Xcode/Model/Config/Xcode+DeviceFamily.swift @@ -0,0 +1,30 @@ +import Foundation + +extension Xcode { + public enum DeviceFamily: String { + case iphone = "1" + case ipad = "2" + case appletv = "3" + case applewatch = "4" + case homepod = "5" + case mac = "6" + + public var code: String { + switch self { + case .iphone: return "iphone" + case .ipad: return "ipad" + case .appletv: return "appletv" + case .applewatch: return "watch" + case .homepod: return "homepod" + case .mac: return "mac" + } + } + + static func parse(_ rawValue: String?) -> [Self] { + rawValue? + .split { $0 == "," || $0 == " " } + .map(String.init) + .compactMap(Self.init(rawValue:)) ?? [] + } + } +} diff --git a/Sources/Xcode/Model/File/Xcode+File.swift b/Sources/Xcode/Model/File/Xcode+File.swift new file mode 100644 index 0000000..932e1cb --- /dev/null +++ b/Sources/Xcode/Model/File/Xcode+File.swift @@ -0,0 +1,13 @@ +extension Xcode { + public struct File: Codable { + public let name: String? + public let path: String? + public let fullPath: String? + public let label: String? + public let fileType: String? + public let sourceTree: String + public let buildPhase: String? + public let compilerFlags: String? + public let attributes: [String] + } +} diff --git a/Sources/Xcode/Model/File/Xcode+Files.swift b/Sources/Xcode/Model/File/Xcode+Files.swift new file mode 100644 index 0000000..e19b030 --- /dev/null +++ b/Sources/Xcode/Model/File/Xcode+Files.swift @@ -0,0 +1,33 @@ +// MARK: - Xcode.Files + +extension Xcode { + public struct Files: Codable { + public let sources: [File] + public let headers: [File] + public let resources: [File] + public let frameworks: [File] + public let copyFiles: [File] + public let others: [File] + } +} + +extension Xcode.Files { + enum CodingKeys: String, CodingKey { + case sources + case headers + case resources + case frameworks + case copyFiles + case others + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(sources.nonEmpty, forKey: .sources) + try container.encodeIfPresent(headers.nonEmpty, forKey: .headers) + try container.encodeIfPresent(resources.nonEmpty, forKey: .resources) + try container.encodeIfPresent(frameworks.nonEmpty, forKey: .frameworks) + try container.encodeIfPresent(copyFiles.nonEmpty, forKey: .copyFiles) + try container.encodeIfPresent(others.nonEmpty, forKey: .others) + } +} diff --git a/Sources/Xcode/Model/Phase/Xcode+BuildPhase.swift b/Sources/Xcode/Model/Phase/Xcode+BuildPhase.swift new file mode 100644 index 0000000..8825a79 --- /dev/null +++ b/Sources/Xcode/Model/Phase/Xcode+BuildPhase.swift @@ -0,0 +1,48 @@ +// MARK: - Xcode.BuildPhase + +extension Xcode { + public struct BuildPhase: Codable { + public let type: String + public let name: String? + public let files: [BuildPhaseFile] + public let inputPaths: [String] + public let outputPaths: [String] + public let inputFileListPaths: [String] + public let outputFileListPaths: [String] + public let shellScript: String? + public let destination: CopyFilesDestination? + } +} + +extension Xcode.BuildPhase { + enum CodingKeys: String, CodingKey { + case type + case name + case files + case inputPaths + case outputPaths + case inputFileListPaths + case outputFileListPaths + case shellScript + case destination + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + try container.encodeIfPresent(name, forKey: .name) + try container.encodeIfPresent(files.nonEmpty, forKey: .files) + try container.encodeIfPresent(inputPaths.nonEmpty, forKey: .inputPaths) + try container.encodeIfPresent(outputPaths.nonEmpty, forKey: .outputPaths) + try container.encodeIfPresent(inputFileListPaths.nonEmpty, forKey: .inputFileListPaths) + try container.encodeIfPresent(outputFileListPaths.nonEmpty, forKey: .outputFileListPaths) + try container.encodeIfPresent(shellScript, forKey: .shellScript) + try container.encodeIfPresent(destination, forKey: .destination) + } +} + +extension Array { + var nonEmpty: Self? { + isEmpty ? nil : self + } +} diff --git a/Sources/Xcode/Model/Phase/Xcode+BuildPhaseFile.swift b/Sources/Xcode/Model/Phase/Xcode+BuildPhaseFile.swift new file mode 100644 index 0000000..80c6d4a --- /dev/null +++ b/Sources/Xcode/Model/Phase/Xcode+BuildPhaseFile.swift @@ -0,0 +1,9 @@ +extension Xcode { + public struct BuildPhaseFile: Codable { + public let name: String? + public let path: String? + public let fileType: String? + public let compilerFlags: String? + public let attributes: [String] + } +} diff --git a/Sources/Xcode/Model/Phase/Xcode+CopyFilesDestination.swift b/Sources/Xcode/Model/Phase/Xcode+CopyFilesDestination.swift new file mode 100644 index 0000000..0d8940b --- /dev/null +++ b/Sources/Xcode/Model/Phase/Xcode+CopyFilesDestination.swift @@ -0,0 +1,7 @@ +extension Xcode { + public struct CopyFilesDestination: Codable { + public let path: String? + public let subfolder: String? + public let subfolderSpec: UInt? + } +} diff --git a/Sources/Xcode/Model/Project/Xcode+Project.swift b/Sources/Xcode/Model/Project/Xcode+Project.swift new file mode 100644 index 0000000..87dd0b6 --- /dev/null +++ b/Sources/Xcode/Model/Project/Xcode+Project.swift @@ -0,0 +1,118 @@ +import Foundation +import PathKit + +// MARK: - Xcode.Project + +extension Xcode { + public struct Project: Encodable { + public let name: String + public let workspacePath: String + public let projectPath: String + public let preferConfig: String? + public let configs: [String: BuildSettings] + public let packages: Packages + public let targets: [Target] + + public static func load(path: Path, preferConfig: String?) throws -> Self { + if let manifest = Self.manifest(at: path) { + return package(at: manifest) + } + + return try ProjectLoader(path: path, preferConfig: preferConfig).model() + } + + /// The `Package.swift` a path names, directly or as its directory. + static func manifest(at path: Path) -> Path? { + if path.lastComponent == "Package.swift", path.exists { return path } + + let manifest = path + "Package.swift" + return manifest.exists ? manifest : nil + } + + /// A Swift package on its own, described as a project with no targets of its + /// own. + /// + /// Everything a package needs is already generated for a package a project + /// depends on: the rules live under `Packages/`, reached by product labels. + /// A package given directly is the same thing — the one local package of a + /// project that has nothing else in it — so nothing else has to know the + /// input was a manifest. + private static func package(at manifest: Path) -> Self { + let root = manifest.parent().absolute() + + return .init( + name: root.lastComponent, + workspacePath: root.string, + projectPath: (root + manifest.lastComponent).string, + preferConfig: nil, + configs: [:], + packages: .init( + remote: [], + local: [.init(name: root.lastComponent, relativePath: ".")]), + targets: []) + } + } +} + +extension Xcode.Project { + /// The package that was handed to bazelize, when the input was a manifest + /// rather than an `.xcodeproj`. + public var packageRoot: Path? { + let path = Path(projectPath) + guard path.lastComponent == "Package.swift" else { return nil } + return path.parent() + } + + public var config: [String: Xcode.BuildSettings]? { + configs + } + + public var workspaceRoot: Path { + Path(workspacePath) + } + + /// The directory of the local package that declares a product, for the + /// products Xcode references without naming their package. + public var localPackageDirectoryByProduct: [String: String] { + var result: [String: String] = [:] + + for package in packages.local { + let packagePath = workspaceRoot + package.relativePath + let manifest = packagePath + "Package.swift" + guard let content = try? String(contentsOfFile: manifest.string) else { continue } + + let directory = Path(package.relativePath).lastComponent + for product in content.swiftPackageProductNames { + result[product] = directory + } + } + + return result + } + + public var localPackagePathByProduct: [String: String] { + var result: [String: String] = [:] + + for target in targets { + for product in target.dependencies.packageProducts { + if let packagePath = product.packagePath { + result[product.productName] = packagePath + } + } + } + + return result + } +} + +extension String { + fileprivate var swiftPackageProductNames: [String] { + let pattern = #"\.library\s*\(\s*name:\s*"([^"]+)""# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + let range = NSRange(startIndex..., in: self) + return regex.matches(in: self, range: range).compactMap { match in + guard let capture = Range(match.range(at: 1), in: self) else { return nil } + return String(self[capture]) + } + } +} diff --git a/Sources/Xcode/Model/SwiftPM/Xcode+LocalPackage.swift b/Sources/Xcode/Model/SwiftPM/Xcode+LocalPackage.swift new file mode 100644 index 0000000..aa5d704 --- /dev/null +++ b/Sources/Xcode/Model/SwiftPM/Xcode+LocalPackage.swift @@ -0,0 +1,6 @@ +extension Xcode { + public struct LocalPackage: Codable { + public let name: String? + public let relativePath: String + } +} diff --git a/Sources/Xcode/Model/SwiftPM/Xcode+PackageProductDependency.swift b/Sources/Xcode/Model/SwiftPM/Xcode+PackageProductDependency.swift new file mode 100644 index 0000000..4ec5e08 --- /dev/null +++ b/Sources/Xcode/Model/SwiftPM/Xcode+PackageProductDependency.swift @@ -0,0 +1,7 @@ +extension Xcode { + public struct PackageProductDependency: Codable { + public let productName: String + public let package: String? + public let packagePath: String? + } +} diff --git a/Sources/Xcode/Model/SwiftPM/Xcode+Packages.swift b/Sources/Xcode/Model/SwiftPM/Xcode+Packages.swift new file mode 100644 index 0000000..2e6f745 --- /dev/null +++ b/Sources/Xcode/Model/SwiftPM/Xcode+Packages.swift @@ -0,0 +1,6 @@ +extension Xcode { + public struct Packages: Codable { + public let remote: [RemotePackage] + public let local: [LocalPackage] + } +} diff --git a/Sources/Xcode/Model/SwiftPM/Xcode+RemotePackage.swift b/Sources/Xcode/Model/SwiftPM/Xcode+RemotePackage.swift new file mode 100644 index 0000000..0f4e085 --- /dev/null +++ b/Sources/Xcode/Model/SwiftPM/Xcode+RemotePackage.swift @@ -0,0 +1,16 @@ +extension Xcode { + public struct RemotePackage: Codable { + public enum Requirement: Codable, Equatable { + case upToNextMajorVersion(String) + case upToNextMinorVersion(String) + case range(from: String, to: String) + case exact(String) + case branch(String) + case revision(String) + } + + public let name: String? + public let repositoryURL: String? + public let version: Requirement? + } +} diff --git a/Sources/Xcode/Model/Target/Xcode+CodeSign.swift b/Sources/Xcode/Model/Target/Xcode+CodeSign.swift new file mode 100644 index 0000000..2e9999e --- /dev/null +++ b/Sources/Xcode/Model/Target/Xcode+CodeSign.swift @@ -0,0 +1,7 @@ +extension Xcode { + public struct CodeSign: Codable { + public let developmentTeam: String? + public let codeSignStyle: String? + public let codeSignIdentity: String? + } +} diff --git a/Sources/Xcode/Model/Target/Xcode+Dependencies.swift b/Sources/Xcode/Model/Target/Xcode+Dependencies.swift new file mode 100644 index 0000000..40fa4cf --- /dev/null +++ b/Sources/Xcode/Model/Target/Xcode+Dependencies.swift @@ -0,0 +1,39 @@ +// MARK: - Xcode.Dependencies + +extension Xcode { + public struct Dependencies: Codable { + public let targets: [String] + public let packageProducts: [PackageProductDependency] + public let frameworks: [String] + public let sdkDylibs: [String] + public let sdkFrameworks: [String] + /// Directories holding the linked system frameworks, e.g. + /// `/System/Library/PrivateFrameworks`, which the linker does not search by + /// default. + public let sdkFrameworkSearchPaths: [String] + public let weakSDKFrameworks: [String] + } +} + +extension Xcode.Dependencies { + enum CodingKeys: String, CodingKey { + case targets + case packageProducts + case frameworks + case sdkDylibs + case sdkFrameworks + case sdkFrameworkSearchPaths + case weakSDKFrameworks + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(targets.nonEmpty, forKey: .targets) + try container.encodeIfPresent(packageProducts.nonEmpty, forKey: .packageProducts) + try container.encodeIfPresent(frameworks.nonEmpty, forKey: .frameworks) + try container.encodeIfPresent(sdkDylibs.nonEmpty, forKey: .sdkDylibs) + try container.encodeIfPresent(sdkFrameworks.nonEmpty, forKey: .sdkFrameworks) + try container.encodeIfPresent(sdkFrameworkSearchPaths.nonEmpty, forKey: .sdkFrameworkSearchPaths) + try container.encodeIfPresent(weakSDKFrameworks.nonEmpty, forKey: .weakSDKFrameworks) + } +} diff --git a/Sources/Xcode/Model/Target/Xcode+Target.swift b/Sources/Xcode/Model/Target/Xcode+Target.swift new file mode 100644 index 0000000..6e689d2 --- /dev/null +++ b/Sources/Xcode/Model/Target/Xcode+Target.swift @@ -0,0 +1,243 @@ +import Foundation + +// MARK: - Xcode.Target + +extension Xcode { + public struct Target: Encodable { + public let name: String + public let productName: String? + public let productType: String? + public let preferConfig: String? + public let configs: [String: BuildSettings] + public let metadata: TargetMetadata + public let buildPhases: [BuildPhase] + public let files: Files + public let dependencies: Dependencies + } +} + +extension Dictionary where Key == String, Value == Xcode.BuildSettings { + fileprivate var sortedByKey: [(key: Key, value: Value)] { + sorted { lhs, rhs in + lhs.key < rhs.key + } + } + + func prefer(config: String?, _ keyPath: KeyPath) -> T? { + let firstValue = sortedByKey.first?.value[keyPath: keyPath] + guard let config else { return firstValue } + return self[config]?[keyPath: keyPath] ?? firstValue + } + + func prefer(config: String?, _ keyPath: KeyPath) -> T? { + let firstValue = sortedByKey.first?.value[keyPath: keyPath] + guard let config else { return firstValue } + return self[config]?[keyPath: keyPath] ?? firstValue + } +} + +extension Xcode.Target { + public func prefer(_ keyPath: KeyPath) -> T? { + configs.prefer(config: preferConfig, keyPath) + } + + public func prefer(_ keyPath: KeyPath) -> T? { + configs.prefer(config: preferConfig, keyPath) + } + + public var isTest: Bool { + switch productType { + case "com.apple.product-type.bundle.unit-test", + "com.apple.product-type.bundle.ui-testing": + return true + default: + return false + } + } + + public var headers: [String] { + filePaths(files.headers) + } + + /// Headers Xcode copies into the product, i.e. the ones that end up in the + /// module Swift and dependents import. + public var exportedHeaders: [String] { + filePaths( + files.headers.filter { header in + header.attributes.contains("Public") || header.attributes.contains("Private") + }) + } + + /// Headers that stay internal to the target: reachable while compiling its own + /// sources, never part of the module. + public var projectHeaders: [String] { + filePaths( + files.headers.filter { header in + !header.attributes.contains("Public") && !header.attributes.contains("Private") + }) + } + + public var hpps: [String] { + headers.filter { $0.hasSuffix(".hpp") || $0.hasSuffix(".hh") || $0.hasSuffix(".hxx") } + } + + public var srcs: [String] { + filePaths(files.sources) + } + + public var srcs_c: [String] { + sources(ofType: "sourcecode.c.c", extensions: [".c"]) + } + + public var srcs_objc: [String] { + sources(ofType: "sourcecode.c.objc", extensions: [".m"]) + } + + public var srcs_cpp: [String] { + sources(ofType: "sourcecode.cpp.cpp", extensions: [".cc", ".cp", ".cpp", ".cxx"]) + } + + public var srcs_objcpp: [String] { + sources(ofType: "sourcecode.cpp.objcpp", extensions: [".mm"]) + } + + public var srcs_swift: [String] { + sources(ofType: "sourcecode.swift", extensions: [".swift"]) + } + + public var srcs_metal: [String] { + sources(ofType: "sourcecode.metal", extensions: [".metal"]) + } + + /// Xcode compiles by declared file type, which can disagree with the extension + /// (`explicitFileType = sourcecode.cpp.objcpp` on a `.m` file is common for + /// Objective-C code that includes C++). + private func sources(ofType type: String, extensions: [String]) -> [String] { + filePaths( + files.sources.filter { file in + if let fileType = file.fileType, Self.compiledFileTypes.contains(fileType) { + return fileType == type + } + guard let path = file.path else { return false } + return extensions.contains { path.hasSuffix($0) } + }) + } + + private static let compiledFileTypes: Set = [ + "sourcecode.c.c", + "sourcecode.c.objc", + "sourcecode.cpp.cpp", + "sourcecode.cpp.objcpp", + "sourcecode.swift", + "sourcecode.metal", + ] + + public var resources: [String] { + var seen = Set() + return filePaths(files.resources) + .map(\.resourceWrapperPath) + .filter { seen.insert($0).inserted } + } + + public var xibs: [String] { + resources.filter { $0.hasSuffix(".xib") } + } + + public var storyboards: [String] { + resources.filter { $0.hasSuffix(".storyboard") } + } + + public var assets: [String] { + resources.filter { $0.hasSuffix(".xcassets") } + } + + public var strings: [String] { + resources.filter { $0.hasSuffix(".strings") } + } + + public var stringsdict: [String] { + resources.filter { $0.hasSuffix(".stringsdict") } + } + + public var allStrings: [String] { + strings + stringsdict + } + + public var importFrameworks: [String] { + files.frameworks.compactMap(\.path) + } + + public var frameworksSDK: [String] { + dependencies.sdkFrameworks + } + + public var dylibsSDK: [String] { + dependencies.sdkDylibs + } + + public var weakFrameworksSDK: [String] { + dependencies.weakSDKFrameworks + } + + public var frameworkSearchPathsSDK: [String] { + dependencies.sdkFrameworkSearchPaths + } + + public var selectedSettings: Xcode.BuildSettings { + if let preferConfig, let settings = configs[preferConfig] { + return settings + } + if let debug = configs["Debug"] { + return debug + } + if let first = configs.keys.sorted().first, let settings = configs[first] { + return settings + } + return .init(name: "", setting: [:]) + } + + private func filePath(_ file: Xcode.File) -> String? { + guard let path = file.path?.trimmingCharacters(in: CharacterSet(charactersIn: "/")), !path.isEmpty else { + return nil + } + return "Sources/\(path)" + } + + private func filePaths(_ files: [Xcode.File]) -> [String] { + files.compactMap(filePath) + } +} + +extension String { + /// Directories Xcode treats as one resource, however they were discovered. + /// + /// A synchronized root group lists the files inside an asset catalog rather + /// than the catalog, and the catalog is what `actool` compiles and what the + /// rules take as an attribute. + fileprivate static let resourceWrapperExtensions: Set = [ + "bundle", + "docc", + "icon", + "mlpackage", + "scnassets", + "xcassets", + "xcdatamodeld", + "xcstickers" + ] + + /// The path truncated at the wrapper that owns it, or the path itself. + fileprivate var resourceWrapperPath: String { + var components: [String] = [] + + for component in split(separator: "/").map(String.init) { + components.append(component) + + let suffix = component.split(separator: ".").last.map(String.init) ?? "" + if Self.resourceWrapperExtensions.contains(suffix) { + return components.joined(separator: "/") + } + } + + return self + } +} diff --git a/Sources/Xcode/Model/Target/Xcode+TargetMetadata.swift b/Sources/Xcode/Model/Target/Xcode+TargetMetadata.swift new file mode 100644 index 0000000..94dd278 --- /dev/null +++ b/Sources/Xcode/Model/Target/Xcode+TargetMetadata.swift @@ -0,0 +1,10 @@ +extension Xcode { + public struct TargetMetadata: Codable { + public let bundleID: String? + public let moduleName: String? + public let infoPlist: String? + public let entitlements: String? + public let deploymentTargets: [String: String] + public let codeSign: CodeSign + } +} diff --git a/Sources/Xcode/TargetSummaryFormatter.swift b/Sources/Xcode/TargetSummaryFormatter.swift new file mode 100644 index 0000000..931e127 --- /dev/null +++ b/Sources/Xcode/TargetSummaryFormatter.swift @@ -0,0 +1,105 @@ +import Foundation + +// MARK: - Xcode.TargetSummaryFormatter + +extension Xcode { + public enum TargetSummaryFormatter { + public static func format(project: Xcode.Project, target: Xcode.Target) -> String { + var lines: [String] = [] + + lines.append("Target: \(target.name)") + lines.append("Type: \(target.productType ?? "")") + + if let productName = target.productName { + lines.append("Product Name: \(productName)") + } + + lines.append("") + lines.append("Metadata:") + appendValue(target.metadata.bundleID, label: "Bundle ID", to: &lines) + appendValue(target.metadata.moduleName, label: "Module Name", to: &lines) + appendValue(target.metadata.infoPlist, label: "Info.plist", to: &lines) + + if !target.metadata.deploymentTargets.isEmpty { + lines.append(" Deployment Targets:") + for key in target.metadata.deploymentTargets.keys.sorted() { + guard let value = target.metadata.deploymentTargets[key] else { continue } + lines.append(" \(key): \(value)") + } + } + + appendValue(target.metadata.codeSign.codeSignStyle, label: "Code Sign Style", to: &lines) + appendValue(target.metadata.codeSign.developmentTeam, label: "Development Team", to: &lines) + appendValue(target.metadata.codeSign.codeSignIdentity, label: "Code Sign Identity", to: &lines) + + lines.append("") + lines.append("Files:") + appendFiles(target.files.sources, title: "Sources", to: &lines) + appendFiles(target.files.headers, title: "Headers", to: &lines) + appendFiles(target.files.resources, title: "Resources", to: &lines) + appendFiles(target.files.frameworks, title: "Frameworks", to: &lines) + appendFiles(target.files.copyFiles, title: "Copy Files", to: &lines) + appendFiles(target.files.others, title: "Others", to: &lines) + + lines.append("") + lines.append("Dependencies:") + appendList(target.dependencies.targets, title: "Targets", to: &lines) + appendList(target.dependencies.packageProducts.map(\.summaryText), title: "Package Products", to: &lines) + appendList(target.dependencies.frameworks, title: "Frameworks", to: &lines) + appendList(target.dependencies.sdkFrameworks, title: "SDK Frameworks", to: &lines) + + let selectedConfigName = project.preferConfig ?? target.configs.keys.sorted().first + if let selectedConfigName, let settings = target.configs[selectedConfigName] { + lines.append("") + lines.append("Settings [\(selectedConfigName)]:") + for key in settings.keys.sorted() { + guard let value = settings[key] else { continue } + lines.append(" \(key) = \(value)") + } + } + + return lines.joined(separator: "\n") + } + + private static func appendValue(_ value: String?, label: String, to lines: inout [String]) { + guard let value, !value.isEmpty else { return } + lines.append(" \(label): \(value)") + } + + private static func appendFiles(_ files: [Xcode.File], title: String, to lines: inout [String]) { + guard !files.isEmpty else { return } + + lines.append(" \(title):") + for file in files.sorted(by: { $0.summaryPath < $1.summaryPath }) { + lines.append(" - \(file.summaryPath)") + } + } + + private static func appendList(_ values: [String], title: String, to lines: inout [String]) { + guard !values.isEmpty else { return } + + lines.append(" \(title):") + for value in values.sorted() { + lines.append(" - \(value)") + } + } + } +} + +extension Xcode.File { + fileprivate var summaryPath: String { + path ?? name ?? fullPath ?? label ?? "" + } +} + +extension Xcode.PackageProductDependency { + fileprivate var summaryText: String { + if let package, !package.isEmpty { + return "\(package) / \(productName)" + } + if let packagePath, !packagePath.isEmpty { + return "\(packagePath) / \(productName)" + } + return productName + } +} diff --git a/Sources/Xcode/Xcode.swift b/Sources/Xcode/Xcode.swift new file mode 100644 index 0000000..e300f9d --- /dev/null +++ b/Sources/Xcode/Xcode.swift @@ -0,0 +1 @@ +public enum Xcode { } diff --git a/Tests/BazelRulesTests/RulesAppleTests.swift b/Tests/BazelRulesTests/RulesAppleTests.swift index ad59d47..f23ae84 100644 --- a/Tests/BazelRulesTests/RulesAppleTests.swift +++ b/Tests/BazelRulesTests/RulesAppleTests.swift @@ -340,6 +340,8 @@ struct RulesAppleTests { name: "ShareExt", bundle_id: "com.example.share", deps: [":ShareExt_library"], + entitlements: "Sources/ShareExt/ShareExt.entitlements", + families: ["iphone", "ipad"], minimum_os_version: "18.0") #expect( @@ -351,6 +353,11 @@ struct RulesAppleTests { deps = [ ":ShareExt_library", ], + entitlements = "Sources/ShareExt/ShareExt.entitlements", + families = [ + "iphone", + "ipad", + ], minimum_os_version = "18.0", ) """) diff --git a/Tests/BazelRulesTests/RulesSwiftTests.swift b/Tests/BazelRulesTests/RulesSwiftTests.swift index 49137a9..aedf548 100644 --- a/Tests/BazelRulesTests/RulesSwiftTests.swift +++ b/Tests/BazelRulesTests/RulesSwiftTests.swift @@ -210,28 +210,41 @@ struct RulesSwiftTests { func testMixedLanguageLibraryTypedCallWithSelectDefines() { let call = Rules.Swift.Call.mixed_language_library( name: "Core", - srcs: ["A.swift", "B.m"], - defines: .select( + alwayslink: true, + clang_copts: ["-fmodule-name=Core"], + clang_srcs: ["B.m"], + sdk_dylibs: ["libz"], + swift_defines: .select( .various([ .config("Debug"): ["DEBUG"], .default: [], - ]))) + ])), + swift_srcs: ["A.swift"]) #expect( call.text == """ mixed_language_library( name = "Core", - srcs = [ - "A.swift", + alwayslink = True, + clang_copts = [ + "-fmodule-name=Core", + ], + clang_srcs = [ "B.m", ], - defines = select({ + sdk_dylibs = [ + "libz", + ], + swift_defines = select({ "//:Debug": [ "DEBUG", ], "//conditions:default": None }), + swift_srcs = [ + "A.swift", + ], ) """) } diff --git a/Tests/CocoapodTests/Resource/Podfile b/Tests/CocoapodTests/Resource/Podfile deleted file mode 100644 index 1fd40b5..0000000 --- a/Tests/CocoapodTests/Resource/Podfile +++ /dev/null @@ -1,69 +0,0 @@ -{ - "target_definitions": [ - { - "name": "Pods", - "abstract": true, - "user_project_path": "ABCDEF.xcodeproj", - "children": [ - { - "name": "ABCDEF", - "uses_frameworks": { - "linkage": "dynamic", - "packaging": "framework" - }, - "configuration_pod_whitelist": { - "Debug": [ - "Peek" - ] - }, - "dependencies": [ - "Peek", - { - "AFNetworking": [ - "~> 4.0" - ] - }, - "Moya/RxSwift", - "Moya/Combine", - { - "DataCompression": [ - { - "git": "https://github.com/mw99/DataCompression" - } - ] - }, - { - "XLPagerTabStrip": [ - { - "git": "https://github.com/xmartlabs/XLPagerTabStrip", - "branch": "master" - } - ] - }, - { - "SVProgressHUD": [ - { - "git": "https://github.com/SVProgressHUD/SVProgressHUD", - "tag": "2.2.5" - } - ] - }, - { - "TLPhotoPicker": [ - { - "git": "https://github.com/tilltue/TLPhotoPicker", - "commit": "0d0cbbd2d20ed5fd36e5f4052209f5e2d9aaa8b7" - } - ] - } - ], - "use_modular_headers": { - "for_pods": [ - "AFNetworking" - ] - } - } - ] - } - ] -} diff --git a/Tests/CocoapodTests/Resource/Podfile.lock b/Tests/CocoapodTests/Resource/Podfile.lock deleted file mode 100644 index 18f9dcc..0000000 --- a/Tests/CocoapodTests/Resource/Podfile.lock +++ /dev/null @@ -1,90 +0,0 @@ -PODS: - - AFNetworking (4.0.1): - - AFNetworking/NSURLSession (= 4.0.1) - - AFNetworking/Reachability (= 4.0.1) - - AFNetworking/Security (= 4.0.1) - - AFNetworking/Serialization (= 4.0.1) - - AFNetworking/UIKit (= 4.0.1) - - AFNetworking/NSURLSession (4.0.1): - - AFNetworking/Reachability - - AFNetworking/Security - - AFNetworking/Serialization - - AFNetworking/Reachability (4.0.1) - - AFNetworking/Security (4.0.1) - - AFNetworking/Serialization (4.0.1) - - AFNetworking/UIKit (4.0.1): - - AFNetworking/NSURLSession - - Alamofire (5.6.1) - - DataCompression (3.6.0) - - Moya/Combine (15.0.0): - - Moya/Core - - Moya/Core (15.0.0): - - Alamofire (~> 5.0) - - Moya/RxSwift (15.0.0): - - Moya/Core - - RxSwift (~> 6.0) - - Peek (5.3.0) - - RxSwift (6.5.0) - - SVProgressHUD (2.2.5) - - TLPhotoPicker (2.1.6) - - XLPagerTabStrip (9.0.0) - -DEPENDENCIES: - - AFNetworking (~> 4.0) - - DataCompression (from `https://github.com/mw99/DataCompression`) - - Moya/Combine - - Moya/RxSwift - - Peek - - SVProgressHUD (from `https://github.com/SVProgressHUD/SVProgressHUD`, tag `2.2.5`) - - TLPhotoPicker (from `https://github.com/tilltue/TLPhotoPicker`, commit `0d0cbbd2d20ed5fd36e5f4052209f5e2d9aaa8b7`) - - XLPagerTabStrip (from `https://github.com/xmartlabs/XLPagerTabStrip`, branch `master`) - -SPEC REPOS: - trunk: - - AFNetworking - - Alamofire - - Moya - - Peek - - RxSwift - -EXTERNAL SOURCES: - DataCompression: - :git: https://github.com/mw99/DataCompression - SVProgressHUD: - :git: https://github.com/SVProgressHUD/SVProgressHUD - :tag: 2.2.5 - TLPhotoPicker: - :commit: 0d0cbbd2d20ed5fd36e5f4052209f5e2d9aaa8b7 - :git: https://github.com/tilltue/TLPhotoPicker - XLPagerTabStrip: - :branch: master - :git: https://github.com/xmartlabs/XLPagerTabStrip - -CHECKOUT OPTIONS: - DataCompression: - :commit: 2c0d48be59acd5bdf1a5352d969d6f24bd7212c9 - :git: https://github.com/mw99/DataCompression - SVProgressHUD: - :git: https://github.com/SVProgressHUD/SVProgressHUD - :tag: 2.2.5 - TLPhotoPicker: - :commit: 0d0cbbd2d20ed5fd36e5f4052209f5e2d9aaa8b7 - :git: https://github.com/tilltue/TLPhotoPicker - XLPagerTabStrip: - :commit: 903b7609b2b2dd1010efb9ee1c6a27edaa9df89b - :git: https://github.com/xmartlabs/XLPagerTabStrip - -SPEC CHECKSUMS: - AFNetworking: 7864c38297c79aaca1500c33288e429c3451fdce - Alamofire: 87bd8c952f9a4454320fce00d9cc3de57bcadaf5 - DataCompression: 06628f9c807b6f152e0da37635633f62fc51dc77 - Moya: 138f0573e53411fb3dc17016add0b748dfbd78ee - Peek: 4209f7aa72d00244616f6b4804739c04cae2e457 - RxSwift: 5710a9e6b17f3c3d6e40d6e559b9fa1e813b2ef8 - SVProgressHUD: 1428aafac632c1f86f62aa4243ec12008d7a51d6 - TLPhotoPicker: 57ad6b54a9cf8c9ec60107be0864d42f3dbe7175 - XLPagerTabStrip: 6af5fe7b41c21f371860df6bac2ddf12818c5103 - -PODFILE CHECKSUM: 3be7930c466451f51fe2597aada6fdbd7e8817d9 - -COCOAPODS: 1.11.3 diff --git a/Tests/CocoapodTests/SPMTests.swift b/Tests/CocoapodTests/SPMTests.swift deleted file mode 100644 index d0fd3c2..0000000 --- a/Tests/CocoapodTests/SPMTests.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// SPMTests.swift -// -// -// Created by Yume on 2022/4/25. -// - -import Foundation -import Testing -@testable import Cocoapod - -struct SPMTests { - // MARK: Internal - - /// Total 9 pod - /// - /// - AFNetworking (4.0.1): - /// x dep's dep - /// - Alamofire (5.6.1) - /// - DataCompression (3.6.0) - /// - /// - Moya/Combine (15.0.0): - /// ? Default Subspec - /// - Moya/Core (15.0.0): - /// - Moya/RxSwift (15.0.0): - /// - /// - Peek (5.3.0) - /// x dep's dep - /// - RxSwift (6.5.0) - /// - SVProgressHUD (2.2.5) - /// - TLPhotoPicker (2.1.6) - /// - XLPagerTabStrip (9.0.0) - @Test - func testParsePodfileLock() async throws { - let code = try Self.strings("Podfile.lock") - let lock = try PodfileLock.parse(code) - let repos = try await lock.repos - #expect(repos.count == 9) - - #expect(repos[0].name == "AFNetworking") - #expect(repos[1].name == "Alamofire") - #expect(repos[2].name == "DataCompression") - #expect(repos[3].name == "Moya") - #expect(repos[4].name == "Peek") - #expect(repos[5].name == "RxSwift") - #expect(repos[6].name == "SVProgressHUD") - #expect(repos[7].name == "TLPhotoPicker") - #expect(repos[8].name == "XLPagerTabStrip") - - #expect(repos[0].url == "https://github.com/AFNetworking/AFNetworking/archive/4.0.1.zip") - #expect(repos[1].url == "https://github.com/Alamofire/Alamofire/archive/5.6.1.zip") - #expect( - repos[2].url, - == "https://github.com/mw99/DataCompression/archive/2c0d48be59acd5bdf1a5352d969d6f24bd7212c9.zip") - #expect(repos[3].url == "https://github.com/Moya/Moya/archive/15.0.0.zip") - #expect(repos[4].url == "https://github.com/shaps80/Peek/archive/5.3.0.zip") - #expect(repos[5].url == "https://github.com/ReactiveX/RxSwift/archive/6.5.0.zip") - #expect(repos[6].url == "https://github.com/SVProgressHUD/SVProgressHUD/archive/2.2.5.zip") - #expect( - repos[7].url, - == "https://github.com/tilltue/TLPhotoPicker/archive/0d0cbbd2d20ed5fd36e5f4052209f5e2d9aaa8b7.zip") - #expect(repos[8].url == "https://github.com/xmartlabs/XLPagerTabStrip/archive/master.zip") - } - - @Test - func testParsePodfile() async throws { - let code = try Self.strings("Podfile") - let podfile = try Podfile.parse(code) - - let result = podfile["ABCDEF"] - let deps = """ - //Vendor/AFNetworking:AFNetworking - //Vendor/DataCompression:DataCompression - //Vendor/Moya:Combine - //Vendor/Moya:RxSwift - //Vendor/Peek:Peek - //Vendor/SVProgressHUD:SVProgressHUD - //Vendor/TLPhotoPicker:TLPhotoPicker - //Vendor/XLPagerTabStrip:XLPagerTabStrip - """ - - #expect(result.joined(separator: "\n") == deps) - } - - // MARK: Private - - private static let sourceFile: URL = .init(fileURLWithPath: #file) - .deletingLastPathComponent() - .appendingPathComponent("Resource") - - private static func resource(_ file: String) -> String { - sourceFile.appendingPathComponent(file).path - } - - private static func strings(_ file: String) throws -> String { - try String(contentsOfFile: resource(file), encoding: .utf8) - } - - #warning("todo default spec is sub spec") -} diff --git a/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift b/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift new file mode 100644 index 0000000..93e82d7 --- /dev/null +++ b/Tests/RepoEnumCoreTests/RepoEnumCoreTests.swift @@ -0,0 +1,146 @@ +import Foundation +import RepoEnumCore +import Testing + +@Test +func parsesOnlyVersionTags() { + #expect(RepoVersionTag(rawTag: "1.2.3")?.normalizedVersion == "1.2.3") + #expect(RepoVersionTag(rawTag: "v4.0.1")?.caseName == "v4_0_1") + #expect(RepoVersionTag(rawTag: "4.0") == nil) + #expect(RepoVersionTag(rawTag: "release-4.0.1") == nil) + #expect(RepoVersionTag(rawTag: "4.0.1-beta.1") == nil) +} + +@Test +func rendersDescendingAndDeduplicatedEnumCases() throws { + let file = RepoEnumFile( + source: .init(name: "XcodeProj", url: "https://github.com/MobileNativeFoundation/rules_xcodeproj"), + tags: [ + try #require(RepoVersionTag(rawTag: "v4.0.1")), + try #require(RepoVersionTag(rawTag: "4.0.0")), + try #require(RepoVersionTag(rawTag: "v4.0.1")), + try #require(RepoVersionTag(rawTag: "3.6.0")), + ]) + + #expect(file.filename == "BazelDep+XcodeProj.swift") + #expect(file.content.contains(#"case v4_0_1 = "4.0.1""#)) + #expect(file.content.contains(#"case v4_0_0 = "4.0.0""#)) + #expect(file.content.contains(#"case v3_6_0 = "3.6.0""#)) + #expect(file.content.contains("static let latest: XcodeProj = .v4_0_1")) + #expect(file.content.firstRange(of: #"case v4_0_1 = "4.0.1""#)?.lowerBound ?? file.content.startIndex < + file.content.firstRange(of: #"case v4_0_0 = "4.0.0""#)?.lowerBound ?? file.content.endIndex) +} + +@Test +func githubErrorIsReadable() async { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + let session = URLSession(configuration: config) + + MockURLProtocolStorage.shared.setHandler { request in + let body = #"{"message":"API rate limit exceeded"}"#.data(using: .utf8)! + let response = HTTPURLResponse( + url: try #require(request.url), + statusCode: 403, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + return (response, body) + } + + let client = GitHubTagClient(session: session, token: nil) + + await #expect(throws: RepoEnumGeneratorError.self) { + _ = try await client.tags(for: "https://github.com/bazelbuild/rules_apple") + } +} + +@Test +func registryVersionsSkipYankedReleases() async throws { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [RegistryMockURLProtocol.self] + let session = URLSession(configuration: config) + + let client = BazelRegistryClient(session: session) + let versions = try await client.versions(forModule: "rules_cc") + + #expect(versions == ["0.2.20", "0.2.22"]) +} + +// MARK: - RegistryMockURLProtocol + +/// Serves one fixed BCR metadata payload; kept separate from `MockURLProtocol` +/// so the two network tests can run in parallel without sharing a handler. +private final class RegistryMockURLProtocol: URLProtocol, @unchecked Sendable { + private static let payload = #""" + { + "versions": ["0.2.20", "0.2.21", "0.2.22"], + "yanked_versions": {"0.2.21": "broken release"} + } + """# + + override class func canInit(with _: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(Self.payload.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() { } +} + +// MARK: - MockURLProtocol + +private final class MockURLProtocol: URLProtocol, @unchecked Sendable { + override class func canInit(with _: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + do { + let (response, data) = try MockURLProtocolStorage.shared.handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() { } +} + +// MARK: - MockURLProtocolStorage + +private final class MockURLProtocolStorage: @unchecked Sendable { + static let shared = MockURLProtocolStorage() + + private let lock = NSLock() + private var currentHandler: @Sendable (URLRequest) throws -> (HTTPURLResponse, Data) = { _ in + fatalError("Handler not set") + } + + func setHandler(_ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)) { + lock.withLock { currentHandler = handler } + } + + func handler(_ request: URLRequest) throws -> (HTTPURLResponse, Data) { + let handler = lock.withLock { currentHandler } + return try handler(request) + } +} diff --git a/Tests/XCodeTests/PropertyTests.swift b/Tests/XCodeTests/PropertyTests.swift deleted file mode 100644 index 358120a..0000000 --- a/Tests/XCodeTests/PropertyTests.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// PropertyTests.swift -// -// -// Created by Yume on 2022/8/3. -// - -import Testing -import XcodeProj -@testable import XCode - -// MARK: - Setting - -private struct Setting: @unchecked Sendable { - // MARK: Lifecycle - - init(_ setting: [String: Any]) { - self.setting = setting - } - - // MARK: Internal - - let setting: [String: Any] - - var plist: [String] { [] } - - var name: String { "" } - - var bundleID: String? { self[#function] } - - var team: String? { self[#function] } - - var swiftVersion: String? { self[#function] } - - var deviceFamily: [DeviceFamily] { [] } - - var sdk: SDK? { nil } - - var iOS: String? { self[#function] } - - var macOS: String? { self[#function] } - - var tvOS: String? { self[#function] } - - var watchOS: String? { self[#function] } - - var driverKit: String? { self[#function] } - - var generateInfoPlist: Bool { false } - - var plistKeys: [String] { [] } - - var infoPlist: String? { nil } - - var defaultPlist: [String] { [] } - - var launch: String? { self[#function] } - - var storyboard: String? { self[#function] } - - var testHost: String? { nil } - var testTargetName: String? { nil } - var bundleLoader: String? { nil } - - var swiftDefine: String? { nil } - var enableModules: Bool { false } - - // MARK: Private - - private subscript(key: String) -> T? { - setting[key] as? T - } -} - -// MARK: - XCodeTests - -enum XCodeTests { - private static let release = Setting([ - "iOS": "9.0", - "macOS": "10.15", - ]) - private static let debug = Setting([ - "iOS": "10.0", - "macOS": "10.15", - ]) - private static let config: [String: Setting] = [ - "Release": release, - "Debug": debug, - ] -} - -// MARK: Test Select -extension XCodeTests { - @Test - func testSelectSame() { - let code = Self.config.select(\.macOS).starlark.text - #expect(code == """ - "10.15" - """) - } - - @Test - func testSelectVarious() { - let code = Self.config.select(\.iOS).starlark.text - #expect(code == """ - select({ - "//:Debug": "10.0", - "//:Release": "9.0" - }) - """) - } -} - -// MARK: Test Prefer -extension XCodeTests { - @Test - func testPreferHit() { - let code = Self.config.prefer(config: "Release", \.iOS) - #expect(code == "9.0") - } - - @Test - func testPreferNotHit() { - let code = Self.config.prefer(config: "Release2", \.iOS) - #expect(code == "10.0") - } - - @Test - func testPreferNoValue() { - let config: [String: Setting] = [:] - let code = config.prefer(config: "Release", \.iOS) - #expect(code == nil) - } -} diff --git a/Tests/XcodeTests/BuildSettingsTests.swift b/Tests/XcodeTests/BuildSettingsTests.swift new file mode 100644 index 0000000..7b1ffc1 --- /dev/null +++ b/Tests/XcodeTests/BuildSettingsTests.swift @@ -0,0 +1,124 @@ +import Testing +@testable import Xcode + +struct BuildSettingsTests { + @Test + func buildSettingsHelpersExposeSemanticValues() { + let settings = Xcode.BuildSettings( + name: "Debug", + setting: [ + "PRODUCT_BUNDLE_IDENTIFIER": "com.example.app", + "TARGETED_DEVICE_FAMILY": "1 2", + ]) + + #expect(settings.metadata.bundleID == "com.example.app") + #expect(settings.platform.deviceFamily.map(\.code) == ["iphone", "ipad"]) + } + + @Test + func buildSettingsPlistHelpersReadExpectedKeys() { + let settings = Xcode.BuildSettings( + name: "Release", + setting: [ + "GENERATE_INFOPLIST_FILE": "YES", + "INFOPLIST_FILE": "App/Info.plist", + "INFOPLIST_KEY_CFBundleDisplayName": "Example", + "INFOPLIST_KEY_UILaunchStoryboardName": "LaunchScreen", + "INFOPLIST_KEY_UIMainStoryboardFile": "Main", + "CURRENT_PROJECT_VERSION": "42", + "MARKETING_VERSION": "2.3", + ]) + + #expect(settings.generatedPlist.enabled) + #expect(settings.plist.infoPlist == "App/Info.plist") + #expect(settings.plist.launch == "LaunchScreen") + #expect(settings.plist.storyboard == "Main") + #expect(settings.generatedPlist.currentProjectVersion == "42") + #expect(settings.generatedPlist.marketingVersion == "2.3") + } + + @Test + func buildSettingsPlatformHelpersReadDeploymentTargets() { + let settings = Xcode.BuildSettings( + name: "Release", + setting: [ + "IPHONEOS_DEPLOYMENT_TARGET": "16.0", + "MACOSX_DEPLOYMENT_TARGET": "14.0", + "WATCHOS_DEPLOYMENT_TARGET": "10.0", + ]) + + #expect(settings.platform.iOS == "16.0") + #expect(settings.platform.macOS == "14.0") + #expect(settings.platform.tvOS == nil) + #expect( + settings.platform.deploymentTargets == + ["iOS": "16.0", "macOS": "14.0", "watchOS": "10.0"]) + } + + @Test + func buildSettingsPlatformHelpersReadAppleFamiliesLiteral() { + let settings = Xcode.BuildSettings( + name: "Release", + setting: [ + "TARGETED_DEVICE_FAMILY": "1 2", + ]) + + #expect(settings.platform.deviceFamily.map(\.code) == ["iphone", "ipad"]) + #expect(settings.platform.appleFamiliesLiteral == #"["iphone", "ipad"]"#) + } + + @Test + func buildSettingsMetadataHelpersReadExpectedKeys() { + let settings = Xcode.BuildSettings( + name: "Release", + setting: [ + "PRODUCT_BUNDLE_IDENTIFIER": "com.example.app", + "PRODUCT_MODULE_NAME": "ExampleModule", + "PRODUCT_NAME": "ExampleApp", + "DEVELOPMENT_TEAM": "TEAM123", + "CODE_SIGN_STYLE": "Automatic", + "CODE_SIGN_IDENTITY": "Apple Development", + ]) + + #expect(settings.metadata.bundleID == "com.example.app") + #expect(settings.metadata.moduleName == "ExampleModule") + #expect(settings.metadata.productName == "ExampleApp") + #expect(settings.metadata.developmentTeam == "TEAM123") + #expect(settings.metadata.codeSignStyle == "Automatic") + #expect(settings.metadata.codeSignIdentity == "Apple Development") + } + + @Test + func buildSettingsResolveNestedVariables() { + let settings = Xcode.BuildSettings( + name: "Release", + setting: [ + "TARGET_NAME": "iina", + "PRODUCT_BUNDLE_IDENTIFIER": "com.colliderli.$(TARGET_NAME)", + ]) + + #expect(settings.metadata.bundleID == "com.colliderli.iina") + } + + @Test + func buildSettingsPlistEntriesRenderCommonInfoPlistKeys() { + let settings = Xcode.BuildSettings( + name: "Release", + setting: [ + "GENERATE_INFOPLIST_FILE": "YES", + "INFOPLIST_KEY_UILaunchStoryboardName": "LaunchScreen", + "INFOPLIST_KEY_UISupportedInterfaceOrientations": "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft", + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents": "YES", + ]) + + let plist = settings.generatedPlist.entries.joined(separator: "\n") + + #expect(plist.contains("UILaunchStoryboardName")) + #expect(plist.contains("LaunchScreen")) + #expect(plist.contains("UISupportedInterfaceOrientations")) + #expect(plist.contains("UIInterfaceOrientationPortrait")) + #expect(plist.contains("UIInterfaceOrientationLandscapeLeft")) + #expect(plist.contains("UIApplicationSupportsIndirectInputEvents")) + #expect(plist.contains("")) + } +} diff --git a/Tests/XcodeTests/EncodingTests.swift b/Tests/XcodeTests/EncodingTests.swift new file mode 100644 index 0000000..2ebff23 --- /dev/null +++ b/Tests/XcodeTests/EncodingTests.swift @@ -0,0 +1,23 @@ +import Foundation +import Testing +@testable import Xcode + +struct EncodingTests { + @Test + func filesEncodingOmitsEmptyCopyFiles() throws { + let value = Xcode.Files( + sources: [], + headers: [], + resources: [], + frameworks: [], + copyFiles: [], + others: []) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(value) + let json = try #require(String(data: data, encoding: .utf8)) + + #expect(json == "{}") + } +} diff --git a/Tests/XcodeTests/PackageDeploymentTests.swift b/Tests/XcodeTests/PackageDeploymentTests.swift new file mode 100644 index 0000000..78cfa0c --- /dev/null +++ b/Tests/XcodeTests/PackageDeploymentTests.swift @@ -0,0 +1,84 @@ +@testable import BazelizeKit +import Foundation +import Testing + +/// What version a package's targets end up compiled at, and when that is a +/// problem worth telling the user about. +struct PackageDeploymentTests { + private func manifest(platforms: [(String, String)]) -> SwiftPM.Manifest { + let entries = platforms.map { platform, version in + """ + {"platformName": "\(platform)", "version": "\(version)"} + """ + }.joined(separator: ",") + + let json = """ + {"name": "Package", "platforms": [\(entries)], "products": [], "targets": [], "dependencies": []} + """ + + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(SwiftPM.Manifest.self, from: Data(json.utf8)) + } + + private func package(platforms: [(String, String)]) -> SwiftPM.Package { + .init( + directory: "Example", + root: "/tmp/Example", + manifest: manifest(platforms: platforms), + isLocal: false, + isRoot: false) + } + + @Test + func declaredVersionWins() { + let deployment = SwiftPM.Deployment(project: ["ios": "14.0"]) + + #expect(deployment.required(package(platforms: [("ios", "16.0")]), platform: "ios") == "16.0") + } + + @Test + func undeclaredPlatformFallsBackToWhatSwiftPMBuilds() { + let deployment = SwiftPM.Deployment(project: ["ios": "14.0"]) + let declared = package(platforms: [("macos", "13.0")]) + + /// The package says nothing about iOS, so SwiftPM's own floor for the + /// platform is what it would be built at. + #expect(deployment.required(declared, platform: "ios") == SwiftPM.Deployment.oldest["ios"]) + #expect(deployment.required(declared, platform: "macos") == "13.0") + } + + @Test + func aPackageAskingForMoreThanTheProjectIsReported() { + let deployment = SwiftPM.Deployment(project: ["ios": "14.0", "macos": "13.0"]) + let unmet = deployment.unmet(package(platforms: [("ios", "16.0"), ("macos", "12.0")])) + + #expect(unmet.count == 1) + #expect(unmet.first?.platform == "ios") + #expect(unmet.first?.required == "16.0") + #expect(unmet.first?.project == "14.0") + } + + @Test + func anUndeclaredPlatformIsNotReported() { + let deployment = SwiftPM.Deployment(project: ["ios": "14.0"]) + + /// SwiftPM raises the consumer to its own floor too, so a package that + /// declares nothing is never why a graph is rejected. + #expect(deployment.unmet(package(platforms: [("macos", "13.0")])).isEmpty) + } + + @Test + func aPackageWithinTheProjectsReachIsNotReported() { + let deployment = SwiftPM.Deployment(project: ["ios": "18.5"]) + + #expect(deployment.unmet(package(platforms: [("ios", "16.0")])).isEmpty) + } + + @Test + func versionsCompareByComponent() { + #expect(SwiftPM.Deployment.isNewer("10.15", than: "10.9")) + #expect(SwiftPM.Deployment.isNewer("16.0", than: "15.4")) + #expect(!SwiftPM.Deployment.isNewer("14.0", than: "14")) + #expect(!SwiftPM.Deployment.isNewer("13.0", than: "14.0")) + } +} diff --git a/Tests/XcodeTests/ProjectLoaderTests.swift b/Tests/XcodeTests/ProjectLoaderTests.swift new file mode 100644 index 0000000..a7702cb --- /dev/null +++ b/Tests/XcodeTests/ProjectLoaderTests.swift @@ -0,0 +1,80 @@ +import PathKit +import Testing +@testable import Xcode + +struct ProjectLoaderTests { + @Test + func mergeLocalPackagesKeepsExplicitEntriesFirstAndDeduplicatesByPath() { + let explicit: [Xcode.LocalPackage] = [ + .init(name: "Local1", relativePath: "Local1"), + .init(name: "Local2", relativePath: "Local2"), + ] + let discovered: [Xcode.LocalPackage] = [ + .init(name: "Local1 (Scanned)", relativePath: "Local1"), + .init(name: "Local3", relativePath: "Local3"), + ] + + let merged = ProjectLoader.mergeLocalPackages( + explicit: explicit, + discovered: discovered) + + #expect(merged.map(\.relativePath) == ["Local1", "Local2", "Local3"]) + #expect(merged.first?.name == "Local1") + #expect(merged.last?.name == "Local3") + } + + @Test + func synchronizedExtensionTargetIncludesExpectedSourceFiles() throws { + let current = Path(#filePath) + .parent() + .parent() + .parent() + let projectPath = current + "app/IceCubesApp/IceCubesApp.xcodeproj" + + let project = try Xcode.Project.load(path: projectPath, preferConfig: nil) + let target = try #require(project.targets.first { $0.name == "IceCubesShareExtension" }) + + #expect(target.files.sources.contains { $0.path == "IceCubesShareExtension/ShareViewController.swift" }) + #expect(!target.files.sources.isEmpty) + } + + @Test + func resolvesXCConfigSettingsForIINACommandLineTarget() throws { + let current = Path(#filePath) + .parent() + .parent() + .parent() + let projectPath = current + "app/iina/IINA.xcodeproj" + + let project = try Xcode.Project.load(path: projectPath, preferConfig: "Release") + let target = try #require(project.targets.first { $0.name == "iina-cli" }) + + #expect(target.prefer(\.platform.sdk) == .macOS) + #expect(target.prefer(\.platform.macOS) == "10.15") + } + + @Test + func classifiesIINAFrameworkDependencies() throws { + let current = Path(#filePath) + .parent() + .parent() + .parent() + let projectPath = current + "app/iina/IINA.xcodeproj" + + let project = try Xcode.Project.load(path: projectPath, preferConfig: "Release") + let target = try #require(project.targets.first { $0.name == "iina" }) + + #expect(target.dependencies.sdkFrameworks.contains("CoreDisplay")) + #expect(target.dependencies.sdkFrameworks.contains("PIP")) + #expect(!target.dependencies.frameworks.contains("CoreDisplay.framework")) + #expect(!target.dependencies.frameworks.contains("PIP.framework")) + /// A dylib the project carries is imported by path, never linked by name + /// out of the SDK. + #expect(!target.dependencies.sdkDylibs.contains("libX11.6")) + #expect((current + "app/iina/deps/lib/libX11.6.dylib").exists + == target.dependencies.frameworks.contains("//Prebuilt:libX11.6")) + #expect(target.files.copyFiles.contains { $0.path == "deps/lib/libX11.6.dylib" }) + #expect(target.files.copyFiles.contains { $0.path == "deps/lib/libXau.6.dylib" }) + #expect(target.files.copyFiles.contains { $0.path == "deps/lib/libXdmcp.6.dylib" }) + } +} diff --git a/Tests/XcodeTests/RoadmapTreeBuilderTests.swift b/Tests/XcodeTests/RoadmapTreeBuilderTests.swift new file mode 100644 index 0000000..1891624 --- /dev/null +++ b/Tests/XcodeTests/RoadmapTreeBuilderTests.swift @@ -0,0 +1,219 @@ +import BazelizeKit +import Foundation +import PathKit +import Testing +@testable import Xcode + +struct RoadmapTreeBuilderTests { + @Test + func buildCreatesTargetTreeAndSymlinks() async throws { + let current = Path(#filePath) + .parent() + .parent() + .parent() + let projectPath = current + "fixture/iOS/Example.xcodeproj" + + let output = Path(NSTemporaryDirectory()) + UUID().uuidString + defer { try? output.delete() } + + let kit = try await Kit(projectPath, nil, outputPath: output) + try await kit.run(projectPath) + + #expect((output + "BUILD").exists) + #expect((output + "MODULE.bazel").exists) + #expect((output + "Package.swift").exists) + #expect((output + "Prebuilt").exists) + #expect((output + "Prebuilt/BUILD").exists) + #expect((output + "Prebuilt/SVProgressHUD.xcframework").exists) + #expect((output + "Targets/Example/Sources").exists) + #expect((output + "Targets/Example/Generated").exists) + #expect((output + "Targets/Example/BUILD").exists) + #expect((output + "Targets/Framework1/BUILD").exists) + #expect((output + "Targets/Static2/BUILD").exists) + + let exampleDir = output + "Targets/Example/Sources/Example" + #expect(exampleDir.isDirectory) + #expect(!exampleDir.isSymlink) + #expect(!(exampleDir + "BUILD").exists) + + let exampleApp = output + "Targets/Example/Sources/Example/ExampleApp.swift" + #expect(exampleApp.isSymlink) + #expect( + try exampleApp.symlinkDestination().absolute().string == + (projectPath.parent() + "Example/ExampleApp.swift").absolute().string) + + let previewAsset = output + "Targets/Example/Sources/Example/Preview Content/Preview Assets.xcassets/Contents.json" + #expect(previewAsset.isSymlink) + #expect( + try previewAsset.symlinkDestination().absolute().string == + (projectPath.parent() + "Example/Preview Content/Preview Assets.xcassets/Contents.json").absolute().string) + + let exampleBuild = try String(contentsOfFile: (output + "Targets/Example/BUILD").string) + #expect(exampleBuild.contains("ios_application(")) + #expect(exampleBuild.contains("name = \"Example\"")) + #expect(exampleBuild.contains("swift_library(")) + #expect(exampleBuild.contains("name = \"Example_swift\"")) + #expect(exampleBuild.contains("alias(")) + #expect(exampleBuild.contains("name = \"Example_library\"")) + #expect(exampleBuild.contains("//Targets/Framework1:Framework1_library")) + #expect(exampleBuild.contains("//Prebuilt:SVProgressHUD")) + /// A target depends on the package product, never on the repository that + /// happens to implement it. + #expect(exampleBuild.contains("//Packages/AnyCodable:AnyCodable")) + #expect(exampleBuild.contains("//Packages/Local1:LocalLib1")) + #expect(exampleBuild.contains("//Packages/Local1:LocalLib2")) + #expect(!exampleBuild.contains("@swiftpkg_")) + #expect(exampleBuild.contains("plist_fragment(")) + + let frameworkBuild = try String(contentsOfFile: (output + "Targets/Framework1/BUILD").string) + #expect(frameworkBuild.contains("ios_framework(")) + #expect(frameworkBuild.contains("name = \"Framework1\"")) + #expect(frameworkBuild.contains("plist_fragment(")) + #expect(frameworkBuild.contains("name = \"plist_default\"")) + #expect(frameworkBuild.contains("infoplists = [")) + #expect(frameworkBuild.contains("\":plist_default\"")) + + let static2Build = try String(contentsOfFile: (output + "Targets/Static2/BUILD").string) + #expect(static2Build.contains("objc_library(")) + #expect(static2Build.contains("name = \"Static2_objc\"")) + + let prebuiltBuild = try String(contentsOfFile: (output + "Prebuilt/BUILD").string) + #expect(prebuiltBuild.contains("apple_dynamic_xcframework_import(")) + #expect(prebuiltBuild.contains("name = \"SVProgressHUD\"")) + + let module = try String(contentsOfFile: (output + "MODULE.bazel").string) + #expect(module.contains("rules_apple")) + #expect(module.contains("rules_swift")) + /// The packages are targets of this workspace, so nothing declares a + /// generator for them. + #expect(!module.contains("rules_swift_package_manager")) + #expect(!module.contains("use_repo(")) + + /// A product of a local package is the rules of its targets. + let localBuild = try String(contentsOfFile: (output + "Packages/Local1/BUILD").string) + #expect(localBuild.contains("swift_library(")) + #expect(localBuild.contains("name = \"LocalTarget1\"")) + #expect(localBuild.contains("module_name = \"LocalTarget1\"")) + /// A product of several targets is a group over them. + #expect(localBuild.contains("swift_library_group(")) + #expect(localBuild.contains("name = \"LocalLib1\"")) + #expect(localBuild.contains("\":LocalTarget1\"")) + #expect(localBuild.contains("tags = [")) + #expect(localBuild.contains("\"manual\"")) + #expect(!localBuild.contains("@swiftpkg_")) + + /// A remote package's sources are linked per target, next to its rules. + let remoteBuild = try String(contentsOfFile: (output + "Packages/AnyCodable/BUILD").string) + #expect(remoteBuild.contains("name = \"AnyCodable\"")) + #expect(remoteBuild.contains("Sources/AnyCodable/**/*.swift")) + #expect(!remoteBuild.contains("@swiftpkg_")) + #expect((output + "Packages/AnyCodable/Sources/AnyCodable").isSymlink) + + /// SwiftPM's working directory is not part of the Bazel workspace. + let ignore = try String(contentsOfFile: (output + ".bazelignore").string) + #expect(ignore.contains(".build")) + } + + @Test + func applicationEmbedsExtensionsInsteadOfLinkingThemAsRegularDeps() async throws { + let current = Path(#filePath) + .parent() + .parent() + .parent() + let projectPath = current + "app/IceCubesApp/IceCubesApp.xcodeproj" + + let output = Path(NSTemporaryDirectory()) + UUID().uuidString + defer { try? output.delete() } + + let kit = try await Kit(projectPath, nil, outputPath: output) + try await kit.run(projectPath) + + let appBuild = try String(contentsOfFile: (output + "Targets/IceCubesApp/BUILD").string) + #expect(appBuild.contains("ios_application(")) + #expect(appBuild.contains("extensions = [")) + #expect(appBuild.contains("//Targets/IceCubesActionExtension:IceCubesActionExtension")) + #expect(appBuild.contains("//Targets/IceCubesAppWidgetsExtensionExtension:IceCubesAppWidgetsExtensionExtension")) + #expect(appBuild.contains("//Targets/IceCubesNotifications:IceCubesNotifications")) + #expect(appBuild.contains("//Targets/IceCubesShareExtension:IceCubesShareExtension")) + #expect(!appBuild.contains("//Targets/IceCubesActionExtension:IceCubesActionExtension_library")) + #expect(!appBuild.contains("//Targets/IceCubesAppWidgetsExtensionExtension:IceCubesAppWidgetsExtensionExtension_library")) + #expect(!appBuild.contains("//Targets/IceCubesNotifications:IceCubesNotifications_library")) + #expect(!appBuild.contains("//Targets/IceCubesShareExtension:IceCubesShareExtension_library")) + } + + @Test + func iinaTargetsGenerateSanitizedModuleNamesAndMacAppRules() async throws { + let current = Path(#filePath) + .parent() + .parent() + .parent() + let projectPath = current + "app/iina/IINA.xcodeproj" + + let output = Path(NSTemporaryDirectory()) + UUID().uuidString + defer { try? output.delete() } + + let kit = try await Kit(projectPath, "Release", outputPath: output) + try await kit.run(projectPath) + + let cliBuild = try String(contentsOfFile: (output + "Targets/iina-cli/BUILD").string) + #expect(cliBuild.contains("module_name = \"iina_cli\"")) + #expect(cliBuild.contains("minimum_os_version = \"10.15\"")) + + let pluginBuild = try String(contentsOfFile: (output + "Targets/iina-plugin/BUILD").string) + #expect(pluginBuild.contains("module_name = \"iina_plugin\"")) + + let appBuild = try String(contentsOfFile: (output + "Targets/iina/BUILD").string) + #expect(appBuild.contains("mixed_language_library(")) + #expect(appBuild.contains("name = \"iina_mixed\"")) + /// `PRODUCT_NAME` comes from the target's xcconfig, and it is the module a + /// target's own sources import: iina's Objective-C includes `IINA-Swift.h`. + #expect(appBuild.contains("module_name = \"IINA\"")) + #expect(appBuild.contains("app_icons = glob([")) + #expect(appBuild.contains("Sources/iina/Assets.xcassets/AppIcon.appiconset/**")) + #expect(appBuild.contains("sdk_frameworks = [")) + #expect(appBuild.contains("\"CoreDisplay\"")) + #expect(appBuild.contains("\"PIP\"")) + #expect(!appBuild.contains("\"CoreDisplay.framework\"")) + #expect(!appBuild.contains("\"PIP.framework\"")) + /// iina links its own dylibs out of `deps/lib`, which the SDK knows nothing + /// about: they are imported by path when the checkout has them, never linked + /// by name. + #expect(!appBuild.contains("\"libX11.6\"")) + #expect(!appBuild.contains("\"libXau.6\"")) + #expect(!appBuild.contains("\"libXdmcp.6\"")) + #expect(!appBuild.contains("cc_import(")) + /// The two command line tools Xcode copies into `Contents/MacOS`. + #expect(appBuild.contains("\"//Targets/iina-cli:iina-cli\": \"MacOS\"")) + #expect(appBuild.contains("\"//Targets/iina-plugin:iina-plugin\": \"MacOS\"")) + #expect(appBuild.contains("//Packages/GRMustache.swift:Mustache")) + #expect(!appBuild.contains("@swiftpkg_")) + #expect(appBuild.contains("macos_application(")) + #expect(appBuild.contains("minimum_os_version = \"10.15\"")) + + let nightlyOutput = Path(NSTemporaryDirectory()) + UUID().uuidString + defer { try? nightlyOutput.delete() } + + let nightlyKit = try await Kit(projectPath, "Nightly", outputPath: nightlyOutput) + try await nightlyKit.run(projectPath) + + let nightlyBuild = try String(contentsOfFile: (nightlyOutput + "Targets/iina/BUILD").string) + #expect(nightlyBuild.contains("Sources/iina/Assets.xcassets/AppIconNightly.appiconset/**")) + + let prebuiltBuild = try String(contentsOfFile: (output + "Prebuilt/BUILD").string) + /// The dylibs iina downloads into `deps/lib` are imported from there when the + /// checkout has them, and left out entirely when it does not. + let hasDylibs = (current + "app/iina/deps/lib/libX11.6.dylib").exists + #expect(prebuiltBuild.contains("libX11.6") == hasDylibs) + #expect(prebuiltBuild.contains("libXau.6") == hasDylibs) + #expect(prebuiltBuild.contains("libXdmcp.6") == hasDylibs) + #expect(!prebuiltBuild.contains("name = \"PIP\"")) + #expect(!prebuiltBuild.contains("name = \"CoreDisplay\"")) + + /// A package whose name carries a dot keeps it: the directory is the name a + /// human refers to the package by. + let mustacheBuild = try String(contentsOfFile: (output + "Packages/GRMustache.swift/BUILD").string) + #expect(mustacheBuild.contains("name = \"Mustache\"")) + #expect(mustacheBuild.contains("name = \"GRMustacheKeyAccess\"")) + #expect(mustacheBuild.contains("objc_library(")) + } +} diff --git a/Tests/XcodeTests/TargetSummaryFormatterTests.swift b/Tests/XcodeTests/TargetSummaryFormatterTests.swift new file mode 100644 index 0000000..efb847d --- /dev/null +++ b/Tests/XcodeTests/TargetSummaryFormatterTests.swift @@ -0,0 +1,118 @@ +import Testing +@testable import Xcode + +struct TargetSummaryFormatterTests { + @Test + func formatTargetSummary() throws { + let target = Xcode.Target( + name: "Example", + productName: "Example", + productType: "com.apple.product-type.application", + preferConfig: "Release", + configs: [ + "Debug": .init( + name: "Debug", + setting: [ + "SWIFT_VERSION": "5.9", + ]), + "Release": .init( + name: "Release", + setting: [ + "INFOPLIST_FILE": "Example/Info.plist", + "IPHONEOS_DEPLOYMENT_TARGET": "16.0", + "PRODUCT_BUNDLE_IDENTIFIER": "com.example.Example", + "SWIFT_VERSION": "5.9", + "TARGETED_DEVICE_FAMILY": "1 2", + ]), + ], + metadata: .init( + bundleID: "com.example.Example", + moduleName: "Example", + infoPlist: "Example/Info.plist", + entitlements: "Example/Example.entitlements", + deploymentTargets: ["iOS": "16.0"], + codeSign: .init( + developmentTeam: nil, + codeSignStyle: "Automatic", + codeSignIdentity: nil)), + buildPhases: [], + files: .init( + sources: [ + .init( + name: "ExampleApp.swift", + path: "Example/ExampleApp.swift", + fullPath: "/tmp/Example/ExampleApp.swift", + label: nil, + fileType: "sourcecode.swift", + sourceTree: "", + buildPhase: "sources", + compilerFlags: nil, + attributes: []), + ], + headers: [], + resources: [ + .init( + name: "Assets.xcassets", + path: "Example/Assets.xcassets", + fullPath: "/tmp/Example/Assets.xcassets", + label: nil, + fileType: "folder.assetcatalog", + sourceTree: "", + buildPhase: "resources", + compilerFlags: nil, + attributes: []), + ], + frameworks: [ + .init( + name: "SVProgressHUD.xcframework", + path: "Vendor/SVProgressHUD.xcframework", + fullPath: "/tmp/Vendor/SVProgressHUD.xcframework", + label: "//Prebuilt:SVProgressHUD", + fileType: "wrapper.xcframework", + sourceTree: "", + buildPhase: "frameworks", + compilerFlags: nil, + attributes: []), + ], + copyFiles: [], + others: []), + dependencies: .init( + targets: ["Framework1"], + packageProducts: [ + .init( + productName: "LocalLib1", + package: nil, + packagePath: "../Local1"), + ], + frameworks: ["//Prebuilt:SVProgressHUD"], + sdkDylibs: [], + sdkFrameworks: ["SwiftUI", "UIKit"], + sdkFrameworkSearchPaths: [], + weakSDKFrameworks: [])) + + let project = Xcode.Project( + name: "Example", + workspacePath: "/tmp", + projectPath: "/tmp/Example.xcodeproj", + preferConfig: "Release", + configs: [:], + packages: .init(remote: [], local: []), + targets: [target]) + + let summary = Xcode.TargetSummaryFormatter.format(project: project, target: target) + + #expect(summary.contains("Target: Example")) + #expect(summary.contains("Type: com.apple.product-type.application")) + #expect(summary.contains("Bundle ID: com.example.Example")) + #expect(summary.contains("Sources:")) + #expect(summary.contains("- Example/ExampleApp.swift")) + #expect(summary.contains("Resources:")) + #expect(summary.contains("Dependencies:")) + #expect(summary.contains("../Local1 / LocalLib1")) + #expect(summary.contains("//Prebuilt:SVProgressHUD")) + #expect(summary.contains("SDK Frameworks:")) + #expect(summary.contains("Settings [Release]:")) + #expect(summary.contains("PRODUCT_BUNDLE_IDENTIFIER = com.example.Example")) + #expect(summary.contains("TARGETED_DEVICE_FAMILY = 1 2")) + } +} diff --git a/Tests/XcodeTests/ToolchainTests.swift b/Tests/XcodeTests/ToolchainTests.swift new file mode 100644 index 0000000..fdc425c --- /dev/null +++ b/Tests/XcodeTests/ToolchainTests.swift @@ -0,0 +1,37 @@ +import Testing +@testable import Xcode + +/// The values Xcode fills in from the toolchain, which a project writes into its +/// `Info.plist` and expects to read back in Xcode's own spelling. +struct ToolchainTests { + @Test + func xcodeVersionIsSpelledAsFourDigits() { + #expect(Toolchain.settings(xcodeVersion: "27.0") == [ + "XCODE_VERSION_ACTUAL": "2700", + "XCODE_VERSION_MAJOR": "2700", + "XCODE_VERSION_MINOR": "2700", + ]) + + #expect(Toolchain.settings(xcodeVersion: "14.3") == [ + "XCODE_VERSION_ACTUAL": "1430", + "XCODE_VERSION_MAJOR": "1400", + "XCODE_VERSION_MINOR": "1430", + ]) + + /// A patch release counts, and only `ACTUAL` carries it. + #expect(Toolchain.settings(xcodeVersion: "14.3.1") == [ + "XCODE_VERSION_ACTUAL": "1431", + "XCODE_VERSION_MAJOR": "1400", + "XCODE_VERSION_MINOR": "1430", + ]) + } + + @Test + func aVersionThatIsNoVersionSetsNothing() { + /// A toolchain that cannot be asked leaves the settings unset, so the + /// reference stays unresolved and the key that carries it is dropped — + /// rather than reaching a bundle as `0`. + #expect(Toolchain.settings(xcodeVersion: "").isEmpty) + #expect(Toolchain.settings(xcodeVersion: "Xcode").isEmpty) + } +} diff --git a/docs/Dependecy_ZH.md b/docs/Dependecy_ZH.md index db22aea..88a0278 100644 --- a/docs/Dependecy_ZH.md +++ b/docs/Dependecy_ZH.md @@ -24,10 +24,10 @@ * 套件版本(`tag`/`commit`),常見存放於 `xxx.lock` file。 * 例外: `local path` 無需版本。 * carthage: `github "SVProgressHUD/SVProgressHUD" "2.2.5"` -> `tag: "2.2.5"` - * 對應關係(`XCode Target` vs `Module`) + * 對應關係(`Xcode Target` vs `Module`) ```ruby -# XCode Target `Target1` -> Module `SVProgressHUD` +# Xcode Target `Target1` -> Module `SVProgressHUD` target 'Target1' do pod 'SVProgressHUD' end @@ -132,7 +132,7 @@ COCOAPODS: 1.11.3 --- -### 套件管理(SPM_XCode) +### 套件管理(SPM_Xcode) > `.lock` 位於 > `xxx.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved` @@ -183,7 +183,7 @@ COCOAPODS: 1.11.3 --- -#### 套件管理(SPM_XCode) 條件 +#### 套件管理(SPM_Xcode) 條件 * [x] 套件來源 * [x] 套件版本 diff --git a/docs/Design.md b/docs/Design.md index 4ce8bc2..6d11dee 100644 --- a/docs/Design.md +++ b/docs/Design.md @@ -4,7 +4,7 @@ ## `Bazelize` Objectives - 1. Migrating to `bazel` with minimal impact on existing `XCode` projects. + 1. Migrating to `bazel` with minimal impact on existing `Xcode` projects. * See [Ref](#Ref) 2. Migrating `xxx.xcodeproj` and its dependencies to `bazel`, for example, `pod`, `spm`. @@ -12,13 +12,13 @@ ### Parsing `xcodeproj` -The project is parsed by [XcodeProj](https://github.com/tuist/XcodeProj) to get the `XCode Target` settings, and then filled into the corresponding `rules`. +The project is parsed by [XcodeProj](https://github.com/tuist/XcodeProj) to get the `Xcode Target` settings, and then filled into the corresponding `rules`. -(Follow-up implmentation direction: `XCode Target` -> middle layer -> generate code) +(Follow-up implmentation direction: `Xcode Target` -> middle layer -> generate code) > `Xcode Target` is treated as [Bazel Packages](https://docs.bazel.build/versions/4.2.1/build-ref.html#packages) -See [`XCode Target` setting](#XCode-Target-setting) +See [`Xcode Target` setting](#Xcode-Target-setting) --- ## Dependency Management @@ -38,22 +38,22 @@ First, let's talk about the code part. Our code will be applied to special rules We are currently focusing on `swift_library` and `objc_library` implementations. -Fortunately, `XCode Target` seems to support only one language. +Fortunately, `Xcode Target` seems to support only one language. > Except for application, we can use `bridge-header` or generated header `${target_name}-Swift.h` -### `XCode Target` type +### `Xcode Target` type -We will start with the `XCode Target` type, and then we will implement the most common types. +We will start with the `Xcode Target` type, and then we will implement the most common types. See [PBXProductType][product_type]. -#### Identifying `XCode Target` type +#### Identifying `Xcode Target` type The criterion are [PBXProductType][product_type] and [XCConfigurationList][config_list]. -### `XCode Target` + `Naming Rule` +### `Xcode Target` + `Naming Rule` `BUILD` file contains two types of rules, `xxx_library` and `main rule`. diff --git a/docs/Design_ZH.md b/docs/Design_ZH.md index fad9083..7fb4c9c 100644 --- a/docs/Design_ZH.md +++ b/docs/Design_ZH.md @@ -4,7 +4,7 @@ ## `Bazelize` 的目標 - 1. 在儘量不影響現有 `XCode` 專案的情況下,達成轉移到 `bazel` 的過程。 + 1. 在儘量不影響現有 `Xcode` 專案的情況下,達成轉移到 `bazel` 的過程。 * 見 [Ref](#Ref) 2. 將 `xxx.xcodeproj` 以及其相依套件,如 `pod`, `spm` ...,轉移至 `bazel`。 @@ -12,13 +12,13 @@ ### 解析 `xcodeproj` -我們能透過 [XcodeProj](https://github.com/tuist/XcodeProj) 去解析,得到其 `XCode Target` 設定,最後填入到對應的 `rules`。 +我們能透過 [XcodeProj](https://github.com/tuist/XcodeProj) 去解析,得到其 `Xcode Target` 設定,最後填入到對應的 `rules`。 -(後續實作方向: `XCode Target` -> 中間層 -> generate code) +(後續實作方向: `Xcode Target` -> 中間層 -> generate code) -> `XCode Target` 將視為 [Bazel Packages](https://docs.bazel.build/versions/4.2.1/build-ref.html#packages) +> `Xcode Target` 將視為 [Bazel Packages](https://docs.bazel.build/versions/4.2.1/build-ref.html#packages) -見 [`XCode Target` setting](#XCode-Target-setting) +見 [`Xcode Target` setting](#Xcode-Target-setting) --- @@ -40,23 +40,23 @@ 我們目前會著重在 `swift_library` 及 `objc_library` 的實作。 -所幸,`XCode Target` 似乎同時只支援一種語言。 +所幸,`Xcode Target` 似乎同時只支援一種語言。 > 例外: application 可透過 `bridge-header` 或 generated header `${target_name}-Swift.h`, -### `XCode Target` type +### `Xcode Target` type -我們先從 `XCode Target` type 暸解起,初步我們會先實作較為常見的幾種 type。 +我們先從 `Xcode Target` type 暸解起,初步我們會先實作較為常見的幾種 type。 見 [PBXProductType][product_type] -### 辨識 `XCode Target` type +### 辨識 `Xcode Target` type 主要由 [PBXProductType][product_type] 以及 [XCConfigurationList][config_list] 作為判斷標準。 -### `XCode Target` + `Naming Rule` +### `Xcode Target` + `Naming Rule` `BUILD` file 主要由兩種 rule 組成,`xxx_library` and `main rule`。 @@ -204,7 +204,7 @@ ios_application( --- -## `XCode Target` setting +## `Xcode Target` setting * [ ] type(application/framework/...) * [ ] setting @@ -242,7 +242,7 @@ prefix `INFOPLIST_KEY_` ## 建議事項 - * XCode Target -> 中間層實作 + * Xcode Target -> 中間層實作 * 多語言 Target * 支援 plugin diff --git a/docs/Roadmap.md b/docs/Roadmap.md new file mode 100644 index 0000000..7ebf99c --- /dev/null +++ b/docs/Roadmap.md @@ -0,0 +1,125 @@ +# Tree + +## Goal + +This is the ideal output tree after running `bazelize`. + +- Tree layout is based on filesystem paths relative to `.xcodeproj` +- Tree layout does not follow Xcode logical groups +- Target metadata is handled by Bazel files instead of being emitted as files in the tree + +## Root + +```text +$Output/ <- Bazel Root + BUILD + MODULE.bazel + Package.swift <- generated if have SwiftPM + + Targets/ + $Target1/ + BUILD + Sources/ + Generated/ + + Packages/ + $Package1/ + BUILD + Package/ + Generated/ + + Prebuilt/ + BUILD + A.xcframework +``` + +## Target Layout + +Each target has its own directory under `Targets/`. + +```text +Targets/ + $Target/ + BUILD + Sources/ + Generated/ +``` + +- `Sources/` contains all filesystem entries related to the target +- `Sources/` includes source files, headers, and resources +- file entries keep their original relative path from the `.xcodeproj` root +- directory entries are symlinked as directories and are not flattened +- multiple targets may reference the same source path + +## Path Rules + +- Paths are resolved from the `.xcodeproj` relative path +- Xcode logical groups do not affect output layout + +Example: + +```text +Xcode: +App + UI + A.swift + +Real path: +A.swift + +Output: +Sources/A.swift -> /A.swift +``` + +Another example: + +```text +Real paths: +A.swift +B/B.swift +C/ + a.swift + b.swift + c.swift + +Target entries: +A.swift +B/B.swift +C/ + +Output: +Sources/A.swift -> /A.swift +Sources/B/B.swift -> /B/B.swift +Sources/C -> /C +``` + +## Package Layout + +Each Swift package the project depends on has its own directory under +`Packages/`, whoever generates its rules. + +```text +Packages/ + $Package/ + BUILD + Package/ + Generated/ +``` + +- the directory is named after the package as a human reads it: the last path + component of the URL without `.git`, or the directory name of a local package +- `Package/` is one symlink to the package's sources, remote or local +- a product is a label in this directory, so `//Packages/$Package:$Product` is + what a target depends on regardless of how the rules are generated + +See [SwiftPM](SPM.md) for what the rules themselves look like. + +## Special Directories + +- `Generated/` is target-local or package-local and reserved for files generated for it +- `Prebuilt/` is global at the root level and stores prebuilt binaries +- `Packages/` is global at the root level and stores the Swift packages' rules + +## Deferred + +- missing-file behavior will be defined later diff --git a/docs/Roadmap_ZH.md b/docs/Roadmap_ZH.md new file mode 100644 index 0000000..e737a8c --- /dev/null +++ b/docs/Roadmap_ZH.md @@ -0,0 +1,124 @@ +# Tree + +## 目標 + +這是目前理想中 `bazelize` 執行完成後的輸出目錄結構。 + +- tree layout 以 `.xcodeproj` 相對路徑為準 +- tree layout 不依照 Xcode logical groups 呈現 +- target metadata 不會以檔案形式輸出,而是交由 Bazel files 處理 + +## Root + +```text +$Output/ <- Bazel Root + BUILD + MODULE.bazel + Package.swift <- 如果有 SwiftPM 則產生 + + Targets/ + $Target1/ + BUILD + Sources/ + Generated/ + + Packages/ + $Package1/ + BUILD + Package/ + Generated/ + + Prebuilt/ + BUILD + A.xcframework +``` + +## Target Layout + +每個 target 都會在 `Targets/` 底下有自己的目錄。 + +```text +Targets/ + $Target/ + BUILD + Sources/ + Generated/ +``` + +- `Sources/` 包含所有和該 target 相關的實體檔案系統 entry +- `Sources/` 內包含 source files、headers、resources +- file entry 會保留其相對於 `.xcodeproj` 的原始子路徑 +- directory entry 會直接以 directory symlink 的形式保留,不會展平 +- 多個 target 可以共享同一個來源路徑 + +## Path Rules + +- 所有路徑都以 `.xcodeproj` 相對路徑解析 +- Xcode logical groups 不影響輸出 layout + +範例: + +```text +Xcode: +App + UI + A.swift + +實際路徑: +A.swift + +輸出: +Sources/A.swift -> /A.swift +``` + +另一個範例: + +```text +實際路徑: +A.swift +B/B.swift +C/ + a.swift + b.swift + c.swift + +Target entries: +A.swift +B/B.swift +C/ + +輸出: +Sources/A.swift -> /A.swift +Sources/B/B.swift -> /B/B.swift +Sources/C -> /C +``` + +## Package Layout + +專案依賴的每個 Swift package 都會在 `Packages/` 底下有自己的目錄,不論它的規則 +是誰產生的。 + +```text +Packages/ + $Package/ + BUILD + Package/ + Generated/ +``` + +- 目錄名取人看得懂的 package 名:remote 用 URL 最後一段去掉 `.git`,local 用目錄名 +- `Package/` 是一條指向該 package 原始碼的 symlink,遠端或本地皆然 +- product 就是這個目錄裡的 label,所以不論規則怎麼產生,target 依賴的都是 + `//Packages/$Package:$Product` + +規則本身長什麼樣見 [SwiftPM](SPM_ZH.md)。 + +## Special Directories + +- `Generated/` 是 target-local 或 package-local,保留給它專屬的 generated files +- `Prebuilt/` 是 root-level global directory,用來放 prebuilt binaries +- `Packages/` 是 root-level global directory,用來放 Swift package 的規則 + +## Deferred + +- 缺檔時的處理行為之後再定義 diff --git a/docs/SPM.md b/docs/SPM.md index 51aaaa3..9db024a 100644 --- a/docs/SPM.md +++ b/docs/SPM.md @@ -1,67 +1,464 @@ -## workspace +# SwiftPM -```bazel -load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") +## Goal -http_archive( - name = "cgrindel_rules_spm", - sha256 = "ba4310ba33cd1864a95e41d1ceceaa057e56ebbe311f74105774d526d68e2a0d", - strip_prefix = "rules_spm-0.10.0", - urls = [ - "http://github.com/cgrindel/rules_spm/archive/v0.10.0.tar.gz", - ], -) +Bazelize generates the Bazel rules for a project's Swift packages itself. It +used to declare `rules_swift_package_manager` (rspm) in `MODULE.bazel` and +write a synthesized `Package.swift`, leaving rspm to generate the package +BUILD files inside an external repository at fetch time. -load( - "@cgrindel_rules_spm//spm:deps.bzl", - "spm_rules_dependencies", -) +This document describes the **output shape**, the mapping from SwiftPM +concepts to rules, and how the switch was staged. It is about artifacts and +responsibility boundaries, not implementation details. -spm_rules_dependencies() +## Why -load( - "@build_bazel_rules_swift//swift:repositories.bzl", - "swift_rules_dependencies", -) +1. rspm's output needed 4 vendored patches for real projects + (`Patches/rspm-*.patch` + `single_version_override`), plus a version gate. +2. We were pinned to rspm 1.15.0: from 1.16 every SwiftPM target is + transitioned to the platform floor it declares itself, and analysis fails + when a dependency declares a higher floor. Xcode never does this. Platform + semantics are bazelize's own domain, so generating the rules here removes + the conflict. +3. Header, resource and plist handling for Xcode targets already lives in + bazelize; package targets behave consistently only if they share it. +4. The output is checked-in files: a problem is read in the file, not traced + through a repo rule. +5. One fewer step — no `bazel mod tidy` to maintain the `use_repo` list. -swift_rules_dependencies() +The cost: SwiftPM semantics (traits, registry, binary targets, plugins, macros) +are now our responsibility, and so is the one behaviour still missing — a +package's own platform floor, at the end of this document. -load( - "@build_bazel_rules_swift//swift:extras.bzl", - "swift_rules_extra_dependencies", -) +## Previous output (rspm, for contrast) -swift_rules_extra_dependencies() +```text +App/ +├── MODULE.bazel # bazel_dep(rules_swift_package_manager) +│ # + swift_deps.from_package + use_repo(...) +│ # + single_version_override(patches = …) +├── Patches/ # vendored rspm patches, version gated +│ ├── BUILD +│ └── rspm-*.patch +├── Package.swift # synthesized manifest, read by rspm +├── Package.resolved # seeded from Xcode's Package.resolved +├── config.bazelrc +├── BUILD +├── Prebuilt/ # project-owned .framework/.a/.dylib (symlinks) +└── Targets// + ├── BUILD + ├── Sources/ # symlink tree into the original sources + ├── Headers// # flattened header tree + ├── Generated/ # BazelizeDefines.h, entitlements, asset symbols + └── CopyFiles// # copy phase destinations ``` -## Workspace - -```bazel -load("@cgrindel_rules_spm//spm:defs.bzl", "spm_pkg", "spm_repositories") - -spm_repositories( - name = "swift_pkgs", - dependencies = [ - spm_pkg( - "https://github.com/apple/swift-log.git", - exact_version = "1.4.2", - products = ["Logging"], - ), - ], -) +The package BUILD files were not there; they were in +`external/rules_swift_package_manager++swift_deps+swiftpkg_/`. + +## Output + +The input is an `.xcodeproj`, or a `Package.swift` — a package handed in directly +is loaded as the one local package of a project with nothing else in it, so +everything below is the same either way. The difference is what a package input +adds: its own test targets, as `swift_test`, because pointing the tool at a +package is pointing it at that package's tests. + +```text +App/ +├── MODULE.bazel # no rspm +├── Package.swift # kept: the only way to rebuild .build/checkouts +├── Package.resolved # kept: the only source of pins +├── config.bazelrc +├── BUILD +├── Prebuilt/ +├── Targets// # unchanged +└── Packages/ # ★ new + └── / + ├── BUILD # the rules for every target of that package + ├── Generated/ # resource bundle accessors, module maps, plists + ├── Sources/ # symlink to that target's sources + └── Artifacts//.xcframework # binary targets ``` -## import +`Patches/` disappears entirely. -```bazel -load("@build_bazel_rules_swift//swift:swift.bzl", "swift_binary") +### How a package's sources get in -swift_binary( - name = "simple", - srcs = ["main.swift"], - visibility = ["//swift:__subpackages__"], - deps = [ - "@swift_pkgs//swift-log:Logging", - ], -) +Every package — remote or local — is a directory in this workspace holding a +generated `BUILD` and one symlink per target into the sources SwiftPM already +has: + +```text +Packages/SFSafeSymbols/ +├── BUILD +└── Sources/ + └── SFSafeSymbols -> /.build/checkouts/SFSafeSymbols/Sources/SFSafeSymbols ``` + +so a target's sources are globbed as `Sources//**/*.swift`. A local +package points at wherever its manifest is, read in place. + +Properties of this choice: + +- Same shape as `Targets/`: a symlink tree plus a generated `BUILD` beside it. + One mechanism, not two. +- No external repositories, so no `use_repo` list and no `bazel mod tidy`. +- Resolution stays SwiftPM's job: bazelize runs `swift package resolve` and + reads each checkout's manifest with `swift package dump-package`, which is + offline and spans every tools version in the graph. +- A link per target, rather than one for the whole checkout, keeps the rest of + the checkout out of the build — a package may carry `BUILD` files of its own. + +The alternative — one `git_repository` per remote package, pinned to the +revision in `Package.resolved` — is hermetic but reintroduces external repos +and fetches sources Bazel already has on disk. + +`Package.swift` and `Package.resolved` therefore stay in the output. The sources +a rule globs live in `.build/checkouts`, and `swift package resolve` in the +output directory is the only thing that can put them back — on a fresh clone, or +after `.build` is cleaned. They are not there for Bazel to read, which is what +rspm needed them for: a mandatory `swift = "//:Package.swift"` label whose +directory its module extension ran SwiftPM in, on every evaluation of the +extension. + +### Who runs SwiftPM + +Every SwiftPM step is the installed toolchain's `swift` command: `swift package +resolve` for the checkouts, `swift package dump-package` per checkout for the +manifests, and `swift build` to run a build tool plugin. Not libSwiftPM, which +this package no longer depends on at all. + +- Plugins cannot move there. Running one needs a build system, and + `SwiftPMDataModel` is deliberately the data model alone — `Build`, + `SPMLLBuild` and SwiftDriver are only in the full `SwiftPM` product. Resolving + with a pinned library while plugins build with the installed toolchain would + put two versions of SwiftPM in one `.build`: the checkouts, the + `Package.resolved` format and the manifest cache would belong to whichever ran + last. One SwiftPM — the same one Xcode uses — is the property worth keeping. +- Depending on it means pinning a branch to match the toolchain, and libSwiftPM + says of itself that the API is unstable and may change at any time. + `dump-package`'s JSON spans every tools version in the graph, and it is + decoded into the few fields the generator reads. +- The cost is measured: 0.6s per manifest, so 10.8s for eighteen checkouts. + Running them concurrently is slower, not faster — 14.5s with eight at a time, + consistent with contention on the shared manifest cache — so the loop stays + sequential. An app in the corpus has around ten packages, which is the six + seconds a single `loadPackageGraph` would save. + +### Label naming + +Every package product — remote or local — has one shape in `Targets/*/BUILD`: + +| Product of | Before | Now | +|---|---|---| +| a remote package | `@swiftpkg_sfsafesymbols//:SFSafeSymbols` | `//Packages/SFSafeSymbols:SFSafeSymbols` | +| a local package | `@swiftpkg_account//:Account` | `//Packages/Account:Account` | + +The directory is named after the package as a human reads it (last path +component of the URL without `.git`, or the directory name for a local +package), so a name with a dot — `//Packages/GRMustache.swift:Mustache` — +works too. + +A product is that label whatever generates it, which is what made the switch a +change to `Packages/` alone: the label shape landed first, as aliases into +rspm, and became the rules themselves without a single target's `deps` moving. +No test pins how a package's rules are produced either. + +## SwiftPM concept → generated rule + +| SwiftPM | Generated | +|---|---| +| Swift target | `swift_library` | +| clang target (C/ObjC/C++) | `objc_library` + `swift_interop_hint`, and a module map when the package ships none | +| system-library target | `cc_library` + `swift_interop_hint` over the module map the package ships | +| binary target (xcframework) | `apple_dynamic_xcframework_import` / `apple_static_xcframework_import` | +| binary target (local archive) | unarchived first, then as above | +| executable target | `swift_binary` | +| test target of the package handed in | `swift_test` | +| executable product | `alias` to the target's binary | +| library product, one target | `alias` | +| library product, several targets | `swift_library_group` | +| `.process` / `.copy` resources | `apple_resource_bundle` + `Generated/ResourceBundleAccessor.swift` | +| auto-discovered resources (xib/xcassets/metal/xcstrings/`.lproj`) | as above; a `.metal` file takes the target's headers into the resource group, because the bundler compiles them as Metal headers | +| `defines` | `-D` flags, not the `defines` attribute, which would propagate to every dependent | +| `headerSearchPath` | `includes`, and the headers there stay inputs even when `exclude` drops the directory | +| `linkedLibrary` / `linkedFramework` | `linkopts` | +| `swiftLanguageMode` | `-swift-version` | +| `enableUpcomingFeature` / `enableExperimentalFeature` | `-enable-upcoming-feature` / `-enable-experimental-feature` | +| `defaultIsolation` | `-default-isolation ` | +| `interoperabilityMode` | `-cxx-interoperability-mode=` | +| `strictMemorySafety` | `-strict-memory-safety` | +| `unsafeFlags` | `copts` | +| build tool plugin, own package | run by SwiftPM at generation time; the sources it wrote go into the target that asked for it | +| build tool plugin, dependency | not run; the plugin is named at the end of the run | +| command plugin | nothing: it runs when someone asks for it by name, never during a build | +| macro target | `swift_compiler_plugin`, and `plugins` on whatever declares the macro | +| traits (SE-0450) | expanded into `-D` and conditional deps per enabled trait | + +Two SwiftPM behaviours are matched on every generated `swift_library`: +`alwayslink`, because SwiftPM always links a package library, and +`always_include_developer_search_paths`, which is how a test-support library +such as `RxTest` finds XCTest. Every generated rule is also tagged `manual`: a +package target is built through the bundle rule that transitions it to a +platform, so a wildcard pattern must not compile an iOS-only package for the +host. + +A C-family target's public headers are linked into a generated interface +directory with its module map beside them, and that directory is the header +search path. clang looks for `module.modulemap` in the directory a header was +found in, so the map has to sit next to the headers, and the checkout is not +ours to write into. A module map is what names a C-family module. Without one the module is named +after the label and the target cannot be imported by the name its own sources +use; a module map the package ships is preferred, because it is the interface +the package intends. Reaching it through a header search path is what lets every +consumer resolve the module — Swift or C-family, this package, another one, or an +Xcode target — since only a Swift consumer is handed a module by the rules. + +A package's sources are linked one target at a time, so the rest of a checkout +stays out of the build, and `.bazelignore` keeps SwiftPM's working directory +out of it too. Both exist for the same reason: a package can carry `BUILD` +files of its own, and Bazel would load them as packages of this workspace. + +A package's platform floor is deliberately ignored — honouring it per package +is exactly the rspm behaviour that pins us to 1.15.0. What that costs is in +stage 2's results below. + +## Stage 0 results (measured) + +Corpus: the **119 packages / 208 non-test targets** the 12 apps expand to, read +from the `dump.json` and `desc.json` rspm generates in its external repos (the +output of `swift package dump-package` and `describe`). + +### Target kinds + +| module type | count | +|---|---| +| SwiftTarget | 170 | +| ClangTarget | 50 | +| BinaryTarget | 2 | +| SystemLibraryTarget | 2 | +| PluginTarget | 1 | + +No macro targets, and no mixed-language targets (SwiftPM does not allow them). +Nothing in the corpus exercises a macro or a source-generating plugin by being +built, so what covers those is `spm/TbCodeGenerater`, whose tests only compile +through a source its own build tool plugin generates. + +### Build settings (targets / packages using them) + +| setting | targets | packages | +|---|---|---| +| `swift.enableUpcomingFeature` | 116 | 5 | +| `c.headerSearchPath` | 44 | 28 | +| `swift.strictMemorySafety` | 23 | 4 | +| `swift.enableExperimentalFeature` | 21 | 14 | +| `swift.define` | 14 | 4 | +| `swift.swiftLanguageMode` | 13 | 13 | +| `swift.defaultIsolation` | 10 | 10 | +| `c.define` | 6 | 3 | +| `linker.linkedLibrary` | 1 | 1 | +| `linker.linkedFramework` | 1 | 1 | +| `swift.unsafeFlags` | 1 | 1 | + +### Other shapes + +- **resources**: 32 packages (37 `.copy`, 4 `.process`) → resource bundles and + a `Bundle.module` accessor are required. +- **manifest shape**: 30 targets list `sources` explicitly, 32 use `exclude`, + 33 set `publicHeadersPath` → file collection for a clang target cannot rely + on convention alone. +- **tools version** ranges from 4.2 to 6.3 (most common: 5.3, 30 packages). +- **plugin usage**: 9 packages, **all SwiftLint** (`SwiftLintPlugin` 5, + `SwiftLintPlugins` 4) — lint only, they generate no source. +- **plugin target**: exactly one, swift-argument-parser's `GenerateManual`, + consumed by nobody in the corpus. +- **binary target**: 2 (Sparkle's remote xcframework, CodeEditLanguages' local + `.zip`). + +### What that means + +Taking "skip a lint-only build tool plugin with a warning" and "do not generate +a plugin target nobody consumes" as rules, **all 119 packages fall into stages +1–2**: + +| Scope of the stage | Packages covered | +|---|---| +| pure Swift libraries, no resources | 58 | +| + clang / resources / binary / system | 61 (119 cumulative) | +| macros, source-generating plugins | 0 (none in the corpus; `spm/TbCodeGenerater` covers a source-generating plugin) | + +The minimum stage each app needs (expanded from each workspace's +`Package.resolved`): + +| app | pins | needs | +|---|---|---| +| Rectangle | 2 | stage 2 | +| SwiftBar | 5 | stage 2 | +| MonitorControl | 6 | stage 2 | +| iina | 4 | stage 2 | +| IceCubesApp | 20 | stage 2 | +| VirtualBuddy | 6 | stage 2 (only needs argument-parser's plugin target skipped) | +| UTM | 15 | stage 2 (same) | +| PlayCover | 8 | stage 2 (same) | +| CotEditor | 29 | stage 2 (+ SwiftLint plugin skipped) | +| CodeEdit | 34 | stage 2 (+ SwiftLint plugin skipped) | + +So **macros and real plugins can be deferred as a whole**: stage 2 covers the +entire corpus. + +## Stage 1 results (measured) + +At this stage only pure-Swift package targets were generated, behind a flag +with rspm still the default. A target whose +kind is not generated yet is skipped with a warning, and so is every target +that depends on it: a library missing a target it links is worse than a library +that is not there at all. + +Across the 7 green macOS apps, `bazel build //...`: + +| app | result | blocking target kind | +|---|---|---| +| stats | builds | — | +| MacPass | builds | — | +| MonitorControl | needs stage 2 | Sparkle, binary target | +| SwiftBar | needs stage 2 | Sparkle, binary target | +| Rectangle | needs stage 2 | MASShortcut, clang target | +| iina | needs stage 2 | GRMustache.swift's `GRMustacheKeyAccess`, clang target | +| VirtualBuddy | needs stage 2 | BuddyKit, clang target | + +Every failure is a missing target kind, not a wrong rule: the products that +reference a skipped target are the only unresolved labels. + +## Stage 2 results (measured) + +Every kind of target a package in the corpus is made of is generated: C-family, +resource-carrying, binary and system-library targets, next to the Swift ones. + +`bazel build //...`, followed by launching the app: + +| app | result | +|---|---| +| MonitorControl, SwiftBar, stats, Rectangle, MacPass, iina, VirtualBuddy | build and run | +| CodeEdit | every package builds; the app's own sources are rejected by Swift 6.4 | +| CotEditor | every package builds; the app's own sources are rejected by Swift 6.4 | +| IceCubesApp | every package builds; the app's own sources collide with the iOS 27 SDK (`SwiftUI.Document`) | +| UTM | its packages build; the app needs a prebuilt sysroot, and one source imports a header by basename through Xcode's project headermap | +| PlayCover | `swift package resolve` fails on the package's own manifest | + +The four that do not build fail in code that is not generated here: three in +their own sources against a newer compiler and SDK, one in a package manifest +upstream. + +### Platform versions + +A package declares the platform versions it supports, and SwiftPM compiles each +of its targets at the higher of that and the consumer's. Bazelize compiles every +package target at the project's deployment target: the version lives in the +platform transition of the bundle rule that pulls the target in, and a library +rule has no version of its own. Honouring it per target is what pinned the rspm +dependency at 1.15.0 — later versions transition each target to its own floor +and then fail analysis when a dependency declares a higher one. + +The version a package asks for is still decided, the way SwiftPM decides it: + +1. what the manifest's `platforms:` declares for that platform; +2. else the oldest version SwiftPM builds that platform for — macOS 12, iOS and + tvOS 15, watchOS 9, visionOS 1, Mac Catalyst 15, DriverKit 21; +3. else, for a platform the project builds without naming a version, what the + installed SDK reports: the deployment target of the `XCTest` it ships, which + is how SwiftPM asks the same question. + +That version is compared with the lowest deployment target among the project's +own targets. A package that needs more is named at the end of the run, with both +versions, because the failure otherwise surfaces as an availability error deep +in someone else's source. + +Compiling such a package at the version it asks for is not the answer, because +SwiftPM does not do that either. It rejects the graph: + +```text +error: The package product 'Dep-product' requires minimum platform version 14.0 +for the macOS platform, but this target supports 12.0 +``` + +A module built for a newer platform cannot be imported by an older one — Swift +errors on that too — so the only resolution is the project raising its own +deployment target, or the package lowering what it declares. Saying which +package and which two versions is therefore the whole of it. + +The same experiment shows SwiftPM raises *both* sides to its own floor before +comparing them (the project above declares macOS 11 and is reported as 12), so a +package that declares nothing is never the reason a graph is rejected. Only a +version a manifest states is reported here. + +### Build tool plugins + +A plugin reads whatever it likes under the package directory and puts its output +into the target that asked for it, not into itself. TbCodeGenerater is the shape +of it: a plugin whose tool is an executable target of the same package, reading a +`.tb` file at the package root — a file that belongs to no target and is excluded +from one — and generating a source file for the package's test target. + +Declaring that to Bazel means knowing commands only the plugin can produce, and a +plugin produces them over a protocol private to SwiftPM: the host asks for build +commands over a pipe, and the request carries the whole package graph in +SwiftPM's own `HostToPluginMessage` format, serialized by some five hundred lines +inside SwiftPM. Reimplementing that host ties bazelize to a schema that moves +with every toolchain. + +So SwiftPM runs them. Building a target is what makes it run that target's +plugins — there is no command that only runs them — and it leaves the result +under `.build/plugins/outputs///`. Those files are linked into +`Generated/Plugin/` and handed to the target that asked for the plugin +the way SwiftPM splits them itself: + +- an extension the target compiles (`.swift` for a Swift target, `.c`/`.m`/… for + a C-family one) goes into its `srcs`; +- a header is neither compiled nor bundled: it is an input of the generated + source beside it, which includes it by name — and that is all SwiftPM offers + either, since a hand-written source cannot reach a generated header; +- everything else is a resource of that target, so a target whose only resources + come from a plugin gets a bundle, exactly as it does under SwiftPM. + +What that buys and costs: + +- A plugin's inputs need no declaring, and a `prebuildCommand` writing a whole + directory needs no tree artifact: whatever it wrote is globbed afterwards. +- The generated sources change when bazelize runs again, not when their inputs + do — already true of every file bazelize writes. +- Only a package in the project's own repository is built this way. Running a + plugin costs a SwiftPM build of its package, and doing that for every + dependency that merely lints would make generating a workspace unusable; a + dependency's plugin is named at the end of the run instead. +- A plugin that could not run is named too: what the target loses is whatever + the plugin generates, and the compile error names those files rather than the + plugin. + +## Stages and exit criteria + +The exit criterion is the same at every stage: **the 12 apps at least hold +their ground** (the 7 green ones stay green, the blocked ones keep the same +reason), plus the 114 unit tests and the iOS fixture. + +| Stage | Scope | Goal | +|---|---|---| +| 0 ✅ | measure the corpus | see above | +| 0.5 ✅ | the `//Packages` facade (aliases into rspm) | all apps; label shape settled | +| 1 ✅ | pure Swift library targets, `swiftLanguageMode` / `define` / upcoming and experimental features / `strictMemorySafety` / `defaultIsolation` / `interoperabilityMode` / `unsafeFlags`; unsupported kinds skipped with a warning, together with their dependents; behind a flag, rspm still the default | 58 packages build on their own | +| 2 ✅ | clang targets (`headerSearchPath` / `publicHeadersPath` / explicit `sources` / `exclude` / module maps), resources + `Bundle.module` accessor, binary targets (remote xcframework and local archive), system libraries | the 7 green apps build and run; every package of the other five builds | +| 3 ✅ | macro targets; per-target platform versions (nothing to build — SwiftPM rejects such a graph, so the report is the answer); build tool plugins, run by SwiftPM at generation time | `spm/TbCodeGenerater`'s tests pass through a plugin-generated source | +| 4 ✅ | the rspm dependency, `Patches/`, the version gate and the mode flag are gone | the 7 green apps build and run | + +Stage 4 removed the alternative rather than keeping a flag: two paths would +mean two dependency graphs, and the generated one is at least as good on every +app in the corpus. + +## Not done + +- **Registry packages (`.package(id:)`)**: no stage implements them. Nothing in + the corpus uses one, and SwiftPM resolves them into checkouts itself, so the + work is recognising one more kind of dependency rather than changing the shape + of the output. Until then a registry package's targets are skipped as + unresolvable and named at the end of the run, like every other unsupported + kind. diff --git a/docs/SPM_ZH.md b/docs/SPM_ZH.md new file mode 100644 index 0000000..595d83b --- /dev/null +++ b/docs/SPM_ZH.md @@ -0,0 +1,417 @@ +# SwiftPM + +## 目標 + +由 bazelize 自己產生專案裡 Swift package 的 Bazel 規則。以前是在 `MODULE.bazel` +裡宣告 `rules_swift_package_manager`(以下 rspm)、寫一份合成的 `Package.swift`, +package 的 BUILD 由 rspm 在 fetch 階段產生在 external repo 裡。 + +這份文件描述**輸出結構**、SwiftPM 概念到規則的對應,以及這次替換的分階段做法。 +它只談產物形狀與責任邊界,不談實作細節。 + +## 為什麼要換 + +1. rspm 產出的 BUILD 有幾處對真實專案不夠用,當時用 4 個 vendored patch 補 + (`Patches/rspm-*.patch` + `single_version_override`),還得帶版本守門。 +2. 我們被釘在 rspm 1.15.0:≥1.16 會把每個 SwiftPM target 轉場到它自己宣告的 + platform floor,然後在依賴宣告更高版本時 analysis 失敗——Xcode 從不這樣做。 + 平台語義本來就是 bazelize 的主場,自己產生就不會打架。 +3. Xcode target 的 header/resource/plist 處理已經在 bazelize 裡了,package + target 走同一套才會行為一致。 +4. 產物是簽入的檔案,出問題直接讀檔,不必追 repo rule。 +5. 少一段 `bazel mod tidy` 補 `use_repo` 清單的流程。 + +代價:SwiftPM 的語義(traits、registry、binary target、plugin、macro)從此是 +我們的責任;還沒做到的那一項——package 自己的 platform floor——寫在文件最後。 + +## 以前的輸出(rspm 版,作為對照) + +```text +App/ +├── MODULE.bazel # bazel_dep(rules_swift_package_manager) +│ # + swift_deps.from_package + use_repo(...) +│ # + single_version_override(patches = …) +├── Patches/ # vendored rspm patch(版本守門) +│ ├── BUILD +│ └── rspm-*.patch +├── Package.swift # 給 rspm 讀的合成 manifest +├── Package.resolved # 由 Xcode 的 Package.resolved 播種 +├── config.bazelrc +├── BUILD +├── Prebuilt/ # 專案自帶的 .framework/.a/.dylib(symlink) +└── Targets// + ├── BUILD + ├── Sources/ # 指向原始碼的 symlink 樹 + ├── Headers// # 扁平化 header 樹 + ├── Generated/ # BazelizeDefines.h、entitlements、asset symbols + └── CopyFiles// # copy phase 目的地 +``` + +package 的 BUILD 不在那裡,而在 +`external/rules_swift_package_manager++swift_deps+swiftpkg_/`。 + +## 輸出 + +輸入可以是 `.xcodeproj`,也可以是 `Package.swift`——直接傳進來的 package 會被當成 +「一個什麼都沒有的 project 底下唯一那個本地 package」來載入,所以以下結構兩種輸入都 +一樣。package 輸入多出來的是:它自己的測試 target 會產生成 `swift_test`,因為把工具 +指向一個 package,就是指向那個 package 的測試。 + +```text +App/ +├── MODULE.bazel # 不再有 rspm +├── Package.swift # 保留:重建 .build/checkouts 的唯一途徑 +├── Package.resolved # 保留:pin 的唯一來源 +├── config.bazelrc +├── BUILD +├── Prebuilt/ +├── Targets// # 完全不變 +└── Packages/ # ★ 新增 + └── / + ├── BUILD # 該 package 全部 target 的規則(我們產生) + ├── Generated/ # resource bundle accessor、module map、plist + ├── Sources/ # 指向該 target 原始碼的 symlink + └── Artifacts//.xcframework # binary target +``` + +`Patches/` 整組消失。 + +### package 的原始碼怎麼進來 + +每個 package——遠端或本地——都是這個 workspace 裡的一個目錄,裡面放我們產生的 +`BUILD`,以及每個 target 一條指向 SwiftPM 既有原始碼的 symlink: + +```text +Packages/SFSafeSymbols/ +├── BUILD +└── Sources/ + └── SFSafeSymbols -> /.build/checkouts/SFSafeSymbols/Sources/SFSafeSymbols +``` + +所以 target 的原始碼就是 `Sources//**/*.swift`。本地 package 指向它 +manifest 所在的位置,就地讀取。 + +這個選擇的性質: + +- 和 `Targets/` 同一個形狀:symlink 樹加上旁邊產生的 `BUILD`,只有一套機制。 +- 沒有 external repository,所以沒有 `use_repo` 清單,也不需要 `bazel mod tidy`。 +- 解析仍然是 SwiftPM 的事:bazelize 跑 `swift package resolve`,再用 + `swift package dump-package` 讀每個 checkout 的 manifest——離線、而且橫跨 + 依賴圖裡所有 tools version。 +- 一個 target 一條 symlink(而不是整包 checkout 一條),checkout 其餘部分就不會 + 進到 build 裡——package 可能自己帶 `BUILD` 檔。 + +另一個選項是每個遠端 package 產生一個 `git_repository`,用 `Package.resolved` +的 revision 釘住:那是 hermetic 的,但又把 external repo 帶回來,還會重抓一份 +Bazel 手上已經有的原始碼。 + +所以 `Package.swift` 和 `Package.resolved` 留在產物裡。規則 glob 的原始碼位於 +`.build/checkouts`,而唯一能把它們放回去的就是在產物目錄裡跑 +`swift package resolve`——新 clone、或清掉 `.build` 之後都是。它們不是給 Bazel 讀的, +那是 rspm 需要它們的理由:`swift = "//:Package.swift"` 是 mandatory label,它的 +module extension 每次評估都在那個 label 所在目錄跑 SwiftPM。 + +### SwiftPM 由誰執行 + +每一步 SwiftPM 都是使用者安裝的 toolchain 的 `swift` 指令:`swift package resolve` +取得 checkouts、每個 checkout 一次 `swift package dump-package` 讀 manifest、 +`swift build` 讓 build tool plugin 跑起來。不是 libSwiftPM——本 package 現在完全 +不依賴它。 + +- plugin 這一步搬不過去:跑它需要 build system,而 `SwiftPMDataModel` 刻意只有 + data model——`Build`、`SPMLLBuild` 與 SwiftDriver 只在完整的 `SwiftPM` product 裡。 + 用釘住的 library 解析、卻用安裝的 toolchain 建 plugin,等於同一個 `.build` 被兩個 + 版本的 SwiftPM 寫:checkouts、`Package.resolved` 格式、manifest cache 都屬於最後 + 跑的那個。「只有一個 SwiftPM,而且和 Xcode 用的是同一個」是值得保留的性質。 +- 要依賴它就得釘一個對上 toolchain 的 branch,而 libSwiftPM 自己聲明 API 不穩定、 + 隨時可能改。`dump-package` 的 JSON 橫跨依賴圖裡所有 tools version,而且只被解碼成 + 產生器真正要讀的那幾個欄位。 +- 成本量過了:一份 manifest 0.6 秒,18 個 checkout 共 10.8 秒。改成併發 + 更慢而不是更快——同時跑八個是 14.5 秒,和共用 manifest cache 的競爭一致——所以迴圈 + 維持序列。語料裡一個 app 大約十個 package,那六秒就是換成一次 `loadPackageGraph` + 能省下的全部。 + +### Label 命名 + +所有 package product——遠端或本地——在 `Targets/*/BUILD` 裡都是同一個形狀: + +| 對象 | 之前 | 現在 | +|---|---|---| +| 遠端 package 的 product | `@swiftpkg_sfsafesymbols//:SFSafeSymbols` | `//Packages/SFSafeSymbols:SFSafeSymbols` | +| 本地 package 的 product | `@swiftpkg_account//:Account` | `//Packages/Account:Account` | + +目錄名取人看得懂的 package 名(remote 用 URL 最後一段去掉 `.git`,local 用 +目錄名),所以 `//Packages/GRMustache.swift:Mustache` 這種帶點的名字也成立。 + +不論規則是誰產生的,product 就是這個 label——這也是這次替換只動 `Packages/` 的 +原因:label 形狀先落地(當時是指向 rspm 的 alias),之後換成規則本體,沒有任何 +target 的 `deps` 需要改。測試也不釘 package 的規則是怎麼產生的。 + +## SwiftPM 概念 → 產出的規則 + +| SwiftPM | 產出 | +|---|---| +| Swift target | `swift_library` | +| clang target(C/ObjC/C++) | `objc_library` + `swift_interop_hint`,package 沒帶 module map 時我們產生一份 | +| system-library target | `cc_library` + `swift_interop_hint`,用 package 自己帶的 module map | +| binary target(xcframework) | `apple_dynamic_xcframework_import` / `apple_static_xcframework_import` | +| binary target(本地 archive) | 先解壓,再同上 | +| executable target | `swift_binary` | +| 傳進來那個 package 的測試 target | `swift_test` | +| executable product | `alias` 指向該 target 的 binary | +| library product,單一 target | `alias` | +| library product,多個 target | `swift_library_group` | +| `.process` / `.copy` resources | `apple_resource_bundle` + `Generated/ResourceBundleAccessor.swift` | +| auto-discovered resources(xib/xcassets/metal/xcstrings/`.lproj`) | 同上;有 `.metal` 時該 target 的 header 也一起進 resource group,因為 bundler 會把它們當 Metal header 編 | +| `defines` | `-D` flag,不用 `defines` 屬性——那會往每個下游傳 | +| `headerSearchPath` | `includes`,而且該目錄被 `exclude` 丟掉時 header 仍然留作輸入 | +| `linkedLibrary` / `linkedFramework` | `linkopts` | +| `swiftLanguageMode` | `-swift-version` | +| `enableUpcomingFeature` / `enableExperimentalFeature` | `-enable-upcoming-feature` / `-enable-experimental-feature` | +| `defaultIsolation` | `-default-isolation ` | +| `interoperabilityMode` | `-cxx-interoperability-mode=` | +| `strictMemorySafety` | `-strict-memory-safety` | +| `unsafeFlags` | `copts` | +| build tool plugin(自己的 package) | 產生階段由 SwiftPM 執行;它寫出來的原始碼進「要求它的那個 target」 | +| build tool plugin(依賴的 package) | 不執行;結束時把該 plugin 的名字講出來 | +| command plugin | 不處理:它是有人指名才跑,build 永遠用不到 | +| macro target | `swift_compiler_plugin`,並在宣告該 macro 的 target 上加 `plugins` | +| traits(SE-0450) | 依 enabled traits 展開成 `-D` 與條件依賴 | + +每個產生的 `swift_library` 都對齊兩個 SwiftPM 行為:`alwayslink`,因為 SwiftPM +一律整份連結 package library;還有 `always_include_developer_search_paths`, +`RxTest` 這種測試輔助 library 就是靠它找到 XCTest。每個產生的規則另外都標 +`manual`:package target 是透過會轉場到某個平台的 bundle 規則建起來的,wildcard +pattern 不該把 iOS-only 的 package 拿去編 host。 + +C 系 target 的公開 header 會連結到一個產生出來的 interface 目錄,module map 就放在 +同一層,而那個目錄就是 header search path。clang 只在「找到 header 的那個目錄」找 +`module.modulemap`,所以 map 必須和 header 同層,而 checkout 不是我們能寫的地方。 + +module map 決定 C 系模組叫什麼。沒有它,模組名會由 label 推導出來,原始碼就沒辦法 +用自己寫的名字 import;package 自己帶的 map 優先,因為那是它想提供的介面。用 header +search path 找得到,是每個消費端都能解到模組的原因——Swift 或 C 系、同一個 package、 +別的 package、或 Xcode target 都一樣,因為只有 Swift 端的模組是規則給的。 + +package 的原始碼是一個 target 一條 symlink,checkout 其餘部分不會進 build; +`.bazelignore` 也把 SwiftPM 的工作目錄排除在外。兩件事同一個理由:package 可能 +自己帶 `BUILD` 檔,Bazel 會把它當成這個 workspace 的 package 去載。 + +package 自己宣告的 platform floor 是**故意忽略**的——逐 package 遵守它,正是把 +我們釘在 rspm 1.15.0 的那個行為。代價寫在下面階段 2 的結果裡。 + +## 階段 0 的結果(已量測) + +語料:12 個 app 目前展開出的 **119 個 package/208 個非測試 target**(讀 rspm +產生在 external repo 裡的 `dump.json` 與 `desc.json`,也就是 +`swift package dump-package` 與 `describe` 的輸出)。 + +### target 種類 + +| module type | 數量 | +|---|---| +| SwiftTarget | 170 | +| ClangTarget | 50 | +| BinaryTarget | 2 | +| SystemLibraryTarget | 2 | +| PluginTarget | 1 | + +沒有 macro target,也沒有混合語言 target(SwiftPM 本來就不允許)。語料裡沒有任何 +東西會「靠建起來」驗證 macro 或會產生原始碼的 plugin,這兩件事由 +`spm/TbCodeGenerater` 守著——它的測試只有靠自己 build tool plugin 產生的原始碼才編得過。 + +### build settings(用到的 target 數/package 數) + +| setting | targets | packages | +|---|---|---| +| `swift.enableUpcomingFeature` | 116 | 5 | +| `c.headerSearchPath` | 44 | 28 | +| `swift.strictMemorySafety` | 23 | 4 | +| `swift.enableExperimentalFeature` | 21 | 14 | +| `swift.define` | 14 | 4 | +| `swift.swiftLanguageMode` | 13 | 13 | +| `swift.defaultIsolation` | 10 | 10 | +| `c.define` | 6 | 3 | +| `linker.linkedLibrary` | 1 | 1 | +| `linker.linkedFramework` | 1 | 1 | +| `swift.unsafeFlags` | 1 | 1 | + +### 其他形狀 + +- **resources**:32 個 package(`.copy` 37 處、`.process` 4 處)→ 需要 resource + bundle 與 `Bundle.module` accessor。 +- **manifest 形狀**:明列 `sources` 30 個 target、`exclude` 32、 + `publicHeadersPath` 33 → clang target 的檔案收集不能只靠慣例。 +- **tools-version** 從 4.2 到 6.3 都有(最多的是 5.3,30 個)。 +- **plugin 使用**:9 個 package,**全部是 SwiftLint**(`SwiftLintPlugin` 5 個、 + `SwiftLintPlugins` 4 個)——只做 lint,不產生原始碼。 +- **plugin target**:只有 1 個,swift-argument-parser 的 `GenerateManual`, + 語料裡沒有人消費它。 +- **binary target**:2 個(Sparkle 的遠端 xcframework、CodeEditLanguages 的本地 + `.zip`)。 + +### 這代表什麼 + +把「lint-only 的 build tool plugin 略過(印警告)」和「不產生沒人消費的 plugin +target」當成規則,語料裡**119 個 package 全部落在階段 1–2**: + +| 階段支援的範圍 | 覆蓋 package | +|---|---| +| 純 Swift library、無 resource | 58 | +| + clang/resources/binary/system | 61(累計 119) | +| macro、會產生原始碼的 plugin | 0(語料裡沒有;會產生原始碼的 plugin 由 `spm/TbCodeGenerater` 守著) | + +每個 app 需要的最低階段(用各 workspace 的 `Package.resolved` 展開): + +| app | pins | 需要到 | +|---|---|---| +| Rectangle | 2 | 階段 2 | +| SwiftBar | 5 | 階段 2 | +| MonitorControl | 6 | 階段 2 | +| iina | 4 | 階段 2 | +| IceCubesApp | 20 | 階段 2 | +| VirtualBuddy | 6 | 階段 2(只差 argument-parser 的 plugin target 要略過) | +| UTM | 15 | 階段 2(同上) | +| PlayCover | 8 | 階段 2(同上) | +| CotEditor | 29 | 階段 2(+SwiftLint plugin 略過) | +| CodeEdit | 34 | 階段 2(+SwiftLint plugin 略過) | + +也就是說:**macro 與真 plugin 可以整段延後**,階段 2 做完就能覆蓋全部語料。 + +## 階段 1 的結果(已量測) + +這個階段只產生純 Swift 的 package target,由一個 flag 切換,預設仍是 rspm。還不 +支援的種類會略過 +並印警告,依賴它的 target 也一起略過:一個少了它要連結的 target 的 library, +比根本不存在更糟。 + +7 個原本綠燈的 macOS app 跑 `bazel build //...`: + +| app | 結果 | 卡住的 target 種類 | +|---|---|---| +| stats | 建得起來 | — | +| MacPass | 建得起來 | — | +| MonitorControl | 需要階段 2 | Sparkle,binary target | +| SwiftBar | 需要階段 2 | Sparkle,binary target | +| Rectangle | 需要階段 2 | MASShortcut,clang target | +| iina | 需要階段 2 | GRMustache.swift 的 `GRMustacheKeyAccess`,clang target | +| VirtualBuddy | 需要階段 2 | BuddyKit,clang target | + +每個失敗都是「少了一種 target 種類」,不是規則產錯:唯一解不到的 label 就是 +那些指向被略過 target 的 product。 + +## 階段 2 的結果(已量測) + +語料裡 package 會用到的每一種 target 都會產生了:C 系、帶 resource、binary、 +system library,加上原本的 Swift。 + +跑 `bazel build //...`,再啟動 app: + +| app | 結果 | +|---|---| +| MonitorControl、SwiftBar、stats、Rectangle、MacPass、iina、VirtualBuddy | 建得起來也跑得起來 | +| CodeEdit | package 全部建得起來;app 自己的原始碼被 Swift 6.4 擋下 | +| CotEditor | package 全部建得起來;app 自己的原始碼被 Swift 6.4 擋下 | +| IceCubesApp | package 全部建得起來;app 自己的原始碼和 iOS 27 SDK 撞名(`SwiftUI.Document`) | +| UTM | package 都建得起來;app 本身需要預先 build 的 sysroot,另有一處原始碼靠 Xcode 的 project headermap 用檔名 include header | +| PlayCover | `swift package resolve` 在 package 自己的 manifest 上就失敗 | + +沒建起來的四個,失敗點都不在我們產生的東西裡:三個是自己的原始碼碰上更新的 +compiler 與 SDK,一個是上游 manifest。 + +### 平台版本 + +package 會宣告自己支援的平台版本,SwiftPM 編它的 target 時取「自己宣告的」和 +「使用端的」之中較高的那個。bazelize 一律用專案的 deployment target 編所有 package +target:版本存在於「拉它進來的 bundle 規則」的 platform transition 裡,library +規則本身沒有版本這個屬性。逐 target 遵守它,正是 rspm 依賴當時被釘在 1.15.0 的 +原因——之後的版本會把每個 target 轉場到它自己的 floor,然後在依賴宣告更高版本時 +analysis 失敗。 + +package 要求的版本還是會算出來,算法和 SwiftPM 一樣: + +1. manifest 的 `platforms:` 對該平台宣告的值; +2. 沒宣告就用 SwiftPM 建該平台的最低版本——macOS 12、iOS 與 tvOS 15、watchOS 9、 + visionOS 1、Mac Catalyst 15、DriverKit 21; +3. 專案有建該平台但沒寫版本時,問已安裝的 SDK:它附的 `XCTest` 的 deployment + target,就是 SwiftPM 問同一個問題的方式。 + +算出來的值會和「專案自己的 target 之中最低的 deployment target」比。package 要求 +更高時,會在執行結束時把兩個版本一起講出來——不然失敗會以「別人原始碼深處的 +availability 錯誤」的形式出現。 + +「用 package 要求的版本去編它」並不是解法,因為 SwiftPM 自己也不這樣做——它直接拒絕 +這張圖: + +```text +error: The package product 'Dep-product' requires minimum platform version 14.0 +for the macOS platform, but this target supports 12.0 +``` + +為較新平台建出來的模組,較舊平台不能 import(Swift 也是直接報錯),所以唯一的解法 +是專案拉高自己的 deployment target,或 package 降低它宣告的版本。把「是哪個 package、 +哪兩個版本」講出來,就是這件事的全部。 + +同一個實驗也顯示 SwiftPM 比較之前會把**兩邊**都拉到它自己的最低版本(上面那個專案 +宣告 macOS 11,錯誤訊息裡是 12),所以「沒宣告」的 package 永遠不會是圖被拒絕的原因。 +因此這裡只回報 manifest 明確宣告的版本。 + +### build tool plugin + +plugin 會讀 package 目錄下任何它想讀的檔案,而且產物是塞進**使用它的那個 target**, +不是塞回自己。TbCodeGenerater 就是這個形狀:plugin 用的工具是同一個 package 的 +executable target,它讀 package 根的一個 `.tb` 檔——那個檔不屬於任何 target,還被 +`exclude` 掉——然後為這個 package 的測試 target 產生一份原始碼。 + +要把這些告訴 Bazel,就得知道「只有 plugin 能產生」的那些 command;而 plugin 產生它們 +走的是 SwiftPM 的私有協定:host 透過 pipe 向 plugin 要 build command,請求裡帶著整張 +package graph,用 SwiftPM 自己的 `HostToPluginMessage` 格式,它內部用大約五百行在 +序列化。自己實作那個 host 等於綁在一個會隨 toolchain 變動的 schema 上。 + +所以讓 SwiftPM 去跑。「建那個 target」就是讓它跑該 target 的 plugin 的唯一方式——沒有 +只跑 plugin 的指令——跑完結果留在 `.build/plugins/outputs///`。那些 +檔案被連結到 `Generated/Plugin/`,並按 SwiftPM 自己的分法交給「要求該 plugin +的那個 target」: + +- target 自己編的副檔名(Swift target 的 `.swift`、C 系 target 的 `.c`/`.m`/…)進 + `srcs`。 +- header 既不編也不打包,它是「它旁邊那份產生原始碼」的輸入——那份原始碼用檔名 include + 它,而 SwiftPM 也只允許這樣:手寫的原始碼 include 不到產生的 header。 +- 其餘一切都是 resource,進該 target 的 resource bundle。只有 plugin 產生 resource 的 + target 也因此會有 bundle,和 SwiftPM 一樣。 + +換到什麼、付出什麼: + +- plugin 的輸入完全不用宣告;`prebuildCommand` 寫出一整個目錄也不需要 tree artifact: + 跑完再 glob 就好。 +- 產生的原始碼在「重跑 bazelize」時更新,不是在輸入改變時更新——這對 bazelize 寫出來的 + 每個檔案本來都成立。 +- 只對「專案自己 repository 裡的 package」這樣做。跑一次 plugin 等於用 SwiftPM 建一次 + 它的 package;對每個只做 lint 的依賴都建一次會讓產生工作癱掉,所以依賴的 plugin 是 + 在結束時具名告知。 +- 跑不起來也會具名告知:那個 target 少掉的是 plugin 該產生的檔案,而 Bazel 端的編譯 + 錯誤只會提到那些檔案,不會提到 plugin。 + +## 分階段與通過條件 + +每一階段的通過條件都一樣:**12 個 app 至少維持現狀**(7 個綠的仍綠、blocked 的 +理由不變),加上 114 單元測試與 iOS fixture。 + +| 階段 | 範圍 | 目標 | +|---|---|---| +| 0 ✅ | 量測語料 | 見上 | +| 0.5 ✅ | `//Packages` facade(alias 指向 rspm) | 所有 app,label 形狀定案 | +| 1 ✅ | 純 Swift library target、`swiftLanguageMode`/`define`/upcoming・experimental feature/`strictMemorySafety`/`defaultIsolation`/`interoperabilityMode`/`unsafeFlags`;不支援的種類連同它的下游一起略過並警告;由一個 flag 切換,預設仍 rspm | 58 個 package 能單獨建起來 | +| 2 ✅ | clang target(`headerSearchPath`/`publicHeadersPath`/明列 `sources`/`exclude`/module map)、resources + `Bundle.module` accessor、binary target(遠端 xcframework 與本地 archive)、system library | 7 個綠燈 app 建得起來也跑得起來;另外五個的 package 全部建得起來 | +| 3 ✅ | macro target;逐 target 的平台版本(不需要做——SwiftPM 自己就會拒絕這種圖,所以回報就是答案);build tool plugin,由 SwiftPM 在產生階段執行 | `spm/TbCodeGenerater` 的測試靠 plugin 產生的原始碼通過 | +| 4 ✅ | rspm 依賴、`Patches/`、版本守門與模式 flag 全部移除 | 7 個綠燈 app 建得起來也跑得起來 | + +階段 4 是把另一條路整個移除,而不是留一個 flag:兩條路就是兩張依賴圖,而語料裡 +每個 app 用自製產生器的結果都不比 rspm 差。 + +## 不做的事 + +- **registry package(`.package(id:)`)**:目前的階段都不實作。語料裡沒有任何一個, + 而 SwiftPM 自己會把它解析進 checkouts,所以要做的時候是「多認一種 dependency 種類」, + 不是改產出的形狀。撞到的時候:該 package 的 target 會被當成解不到而略過並具名回報, + 這和其他不支援的種類一樣。 diff --git a/docs/superpowers/plans/2026-04-09-xcode2-print-target.md b/docs/superpowers/plans/2026-04-09-xcode2-print-target.md new file mode 100644 index 0000000..c294d3c --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-xcode2-print-target.md @@ -0,0 +1,102 @@ +# Xcode2 Print Target Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `--print-target ` option to `bazelize xcode2` that prints a human-readable summary for one target instead of the full project JSON dump. + +**Architecture:** Keep JSON output as the default behavior. Move the new text rendering into a small formatter in the `Xcode2` module so it can be unit tested without invoking the executable target. The CLI command will only choose between JSON mode and summary mode. + +**Tech Stack:** Swift, Swift Argument Parser, XCTest + +--- + +### Task 1: Lock down the text output shape + +**Files:** +- Create: `Tests/Xcode2Tests/TargetSummaryFormatterTests.swift` +- Modify: `Package.swift` + +- [ ] **Step 1: Write the failing test** + +```swift +func testFormatTargetSummary() throws { + let summary = Xcode.TargetSummaryFormatter.format(project: project, target: target) + + XCTAssertTrue(summary.contains("Target: Example")) + XCTAssertTrue(summary.contains("Type: com.apple.product-type.application")) + XCTAssertTrue(summary.contains("Files:")) + XCTAssertTrue(summary.contains("Settings [Release]:")) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --filter TargetSummaryFormatterTests/testFormatTargetSummary` +Expected: FAIL because `TargetSummaryFormatter` and the `Xcode2Tests` target do not exist yet. + +- [ ] **Step 3: Add the new test target** + +```swift +.testTarget( + name: "Xcode2Tests", + dependencies: ["Xcode2"] +), +``` + +- [ ] **Step 4: Run test to verify it still fails for the right reason** + +Run: `swift test --filter TargetSummaryFormatterTests/testFormatTargetSummary` +Expected: FAIL because the formatter symbol is still missing. + +### Task 2: Implement formatter and CLI wiring + +**Files:** +- Create: `Sources/Xcode2/TargetSummaryFormatter.swift` +- Modify: `Sources/Bazelize/Command.swift` + +- [ ] **Step 1: Write minimal formatter implementation** + +```swift +public enum TargetSummaryFormatter { + public static func format(project: Xcode.Project, target: Xcode.Target) -> String { + // build readable text sections + } +} +``` + +- [ ] **Step 2: Wire command-line option** + +```swift +@Option(name: [.customLong("print-target", withSingleDash: false)]) +var printTarget: String? +``` + +- [ ] **Step 3: Select summary mode in the command** + +```swift +if let printTarget { + // find target and print formatted summary +} else { + // existing JSON output +} +``` + +- [ ] **Step 4: Run tests to verify green** + +Run: `swift test --filter TargetSummaryFormatterTests` +Expected: PASS + +### Task 3: Verify the integrated behavior + +**Files:** +- Modify: `Sources/Bazelize/Command.swift` + +- [ ] **Step 1: Run the targeted test suite** + +Run: `swift test --filter TargetSummaryFormatterTests` +Expected: PASS + +- [ ] **Step 2: Run a CLI sanity check** + +Run: `swift run bazelize xcode2 --project fixture/iOS/Example.xcodeproj --print-target Example` +Expected: output starts with `Target: Example` and includes `Type:`, `Files:`, and `Settings [Release]:`. diff --git a/docs/superpowers/plans/2026-04-11-roadmap-command.md b/docs/superpowers/plans/2026-04-11-roadmap-command.md new file mode 100644 index 0000000..ad88665 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-roadmap-command.md @@ -0,0 +1,134 @@ +# Roadmap Command Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `bazelize roadmap` command that creates the roadmap directory tree and target source symlinks for an Xcode project. + +**Architecture:** The CLI command will parse `--project`, `--output`, and optional config, then load `Xcode.Project` and hand off to a small tree builder. The tree builder will create root placeholders, per-target directories, and symlink target-owned filesystem entries into `Sources/` while preserving relative paths from the project root. + +**Tech Stack:** Swift, Swift Argument Parser, PathKit, XCTest + +--- + +### Task 1: Lock down the expected output tree with a failing test + +**Files:** +- Create: `Tests/Xcode2Tests/RoadmapTreeBuilderTests.swift` + +- [ ] **Step 1: Write the failing test** + +```swift +func testBuildCreatesTargetTreeAndSymlinks() throws { + let projectPath = Path.current + "fixture/iOS2/Example.xcodeproj" + let project = try Xcode.Project.load(path: projectPath, preferConfig: nil) + let output = Path(NSTemporaryDirectory()) + UUID().uuidString + + try Xcode.RoadmapTreeBuilder(output: output).build(project: project) + + XCTAssertTrue((output + "Targets/Example/Sources").exists) + XCTAssertTrue((output + "Targets/Example/Generated").exists) + XCTAssertTrue((output + "Targets/Example/BUILD").exists) + XCTAssertTrue((output + "Prebuilt/BUILD").exists) + XCTAssertEqual(try (output + "Targets/Example/Sources/Example/ExampleApp.swift").symlinkDestination(), projectPath.parent() + "Example/ExampleApp.swift") +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --filter RoadmapTreeBuilderTests/testBuildCreatesTargetTreeAndSymlinks` +Expected: FAIL because `RoadmapTreeBuilder` does not exist yet. + +### Task 2: Implement the tree builder + +**Files:** +- Create: `Sources/Xcode2/RoadmapTreeBuilder.swift` + +- [ ] **Step 1: Add a minimal tree builder** + +```swift +public extension Xcode { + struct RoadmapTreeBuilder { + let output: Path + + public func build(project: Xcode.Project) throws { + // create root placeholders + // create target directories + // create symlinks + } + } +} +``` + +- [ ] **Step 2: Materialize root placeholders** + +Run builder code that creates: + +```text +BUILD +MODULE.bazel +Prebuilt/ +Prebuilt/BUILD +``` + +- [ ] **Step 3: Materialize target tree and symlinks** + +Run builder code that creates: + +```text +Targets//BUILD +Targets//Sources/ +Targets//Generated/ +``` + +and symlinks target `sources`, `headers`, `resources`, and `others` entries using project-root-relative paths. + +- [ ] **Step 4: Run the focused test to verify green** + +Run: `swift test --filter RoadmapTreeBuilderTests/testBuildCreatesTargetTreeAndSymlinks` +Expected: PASS + +### Task 3: Wire the CLI command + +**Files:** +- Modify: `Sources/Bazelize/Command.swift` + +- [ ] **Step 1: Add the new command type** + +```swift +struct RoadmapCommand: AsyncParsableCommand { + @Option var project: String + @Option var output: String + @Option var config: String? +} +``` + +- [ ] **Step 2: Register it in the root command** + +Add `RoadmapCommand.self` to `subcommands`. + +- [ ] **Step 3: Call the builder** + +```swift +let dump = try Xcode.Project.load(path: path, preferConfig: config) +try Xcode.RoadmapTreeBuilder(output: Path.current + output).build(project: dump) +``` + +- [ ] **Step 4: Re-run the focused test** + +Run: `swift test --filter RoadmapTreeBuilderTests` +Expected: PASS + +### Task 4: Verify the CLI end to end + +**Files:** +- Modify: `Sources/Bazelize/Command.swift` + +- [ ] **Step 1: Run the test suite for the new builder** + +Run: `swift test --filter RoadmapTreeBuilderTests` +Expected: PASS + +- [ ] **Step 2: Run the command against the fixture** + +Run: `swift run bazelize roadmap --project fixture/iOS2/Example.xcodeproj --output fixture/iOS2_O` +Expected: creates `fixture/iOS2_O/Targets/Example/Sources`, `fixture/iOS2_O/Targets/Example/Generated`, root `Prebuilt`, and representative source symlinks. diff --git a/docs/superpowers/plans/2026-04-15-roadmap-bazelfile.md b/docs/superpowers/plans/2026-04-15-roadmap-bazelfile.md new file mode 100644 index 0000000..694dc8c --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-roadmap-bazelfile.md @@ -0,0 +1,105 @@ +# Roadmap Bazel File Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the roadmap output generate package-shaped Bazel files that move `fixture/iOS2/Example.xcodeproj` toward `bazel run //Example:Example`. + +**Architecture:** Extend `RoadmapTreeBuilder` so it owns both filesystem materialization and minimal Bazel file generation. The builder will emit root files (`BUILD`, `MODULE.bazel`, `Package.swift`) and one package `BUILD` per target using lightweight string templates driven by the `Xcode2` model. + +**Tech Stack:** Swift, PathKit, XCTest + +--- + +### Task 1: Lock down package-shaped output and BUILD content with a failing test + +**Files:** +- Modify: `Tests/Xcode2Tests/RoadmapTreeBuilderTests.swift` + +- [ ] **Step 1: Add assertions for package layout and BUILD text** + +```swift +XCTAssertTrue((output + "Example/Sources").exists) +XCTAssertTrue((output + "Example/BUILD").exists) +XCTAssertTrue(try (output + "Example/BUILD").read().contains("ios_application(")) +XCTAssertTrue(try (output + "Example/BUILD").read().contains("name = \"Example\"")) +XCTAssertTrue(try (output + "Static2/BUILD").read().contains("objc_library(")) +XCTAssertTrue(try (output + "MODULE.bazel").read().contains("rules_swift_package_manager")) +``` + +- [ ] **Step 2: Run the focused test to verify it fails** + +Run: `swift test --filter RoadmapTreeBuilderTests/testBuildCreatesTargetTreeAndSymlinks` +Expected: FAIL because the current builder still writes `Targets/` and empty placeholders. + +### Task 2: Switch to package-shaped filesystem output + +**Files:** +- Modify: `Sources/Xcode2/RoadmapTreeBuilder.swift` + +- [ ] **Step 1: Change target output root** + +Update: + +```swift +let targetRoot = output + target.name +``` + +instead of `output + "Targets" + target.name`. + +- [ ] **Step 2: Re-run the focused test** + +Run: `swift test --filter RoadmapTreeBuilderTests/testBuildCreatesTargetTreeAndSymlinks` +Expected: FAIL on missing BUILD contents rather than wrong directory layout. + +### Task 3: Generate minimal BUILD and module files + +**Files:** +- Modify: `Sources/Xcode2/RoadmapTreeBuilder.swift` + +- [ ] **Step 1: Generate root `MODULE.bazel` and `Package.swift`** + +Add code that writes: + +```python +module(name = "example", version = "0.0.1") +``` + +plus bazel deps and SwiftPM extension wiring. + +- [ ] **Step 2: Generate package BUILD content** + +Implement minimal generation for: + +- `ios_application` +- `ios_framework` +- `swift_library` +- `objc_library` +- `alias` + +- [ ] **Step 3: Wire target and SwiftPM dependencies** + +Generate labels from: + +- `target.dependencies.targets` +- `target.dependencies.packageProducts` +- `target.dependencies.sdkFrameworks` + +- [ ] **Step 4: Re-run the focused test** + +Run: `swift test --filter RoadmapTreeBuilderTests/testBuildCreatesTargetTreeAndSymlinks` +Expected: PASS + +### Task 4: Verify the new Bazel file output + +**Files:** +- Modify: `Tests/Xcode2Tests/RoadmapTreeBuilderTests.swift` + +- [ ] **Step 1: Run the builder test suite** + +Run: `swift test --filter RoadmapTreeBuilderTests` +Expected: PASS + +- [ ] **Step 2: Run the roadmap command against the fixture** + +Run: `swift run bazelize roadmap --project fixture/iOS2/Example.xcodeproj --output fixture/iOS2_O` +Expected: package-shaped output such as `fixture/iOS2_O/Example/BUILD` and `fixture/iOS2_O/Framework1/BUILD`. diff --git a/docs/superpowers/specs/2026-04-11-roadmap-command-design.md b/docs/superpowers/specs/2026-04-11-roadmap-command-design.md new file mode 100644 index 0000000..5c3326e --- /dev/null +++ b/docs/superpowers/specs/2026-04-11-roadmap-command-design.md @@ -0,0 +1,118 @@ +# Roadmap Command Design + +## Goal + +Add a new CLI command that materializes the tree described in `docs/Roadmap.md` from an Xcode project into an output directory. + +The first milestone is intentionally narrow: + +- create the output directory tree +- create per-target `Sources/` and `Generated/` directories +- create the root `Prebuilt/` directory +- create symlinks for target-owned filesystem entries +- create empty `BUILD` files as placeholders + +This milestone does not generate Bazel rules yet. + +## Scope + +Command shape: + +```bash +bazelize roadmap --project fixture/iOS2/Example.xcodeproj --output fixture/iOS2_O +``` + +Inputs: + +- `--project`: path to the `.xcodeproj` +- `--output`: path to the output root +- optional `-c/--config`: preferred config name, reused from `xcode2` + +Outputs: + +- `$Output/BUILD` +- `$Output/MODULE.bazel` +- `$Output/Prebuilt/BUILD` +- `$Output/Targets/$Target/BUILD` +- `$Output/Targets/$Target/Sources/...` +- `$Output/Targets/$Target/Generated/` + +## Path Rules + +The command follows the existing roadmap rules: + +- emitted paths are based on filesystem paths relative to the `.xcodeproj` root +- Xcode logical groups do not affect output layout +- source files, headers, resources, localized files, and other target-owned filesystem entries all go under `Sources/` +- directory entries are symlinked as directories and are not flattened +- target metadata stays in Bazel files and is not emitted as standalone files in the tree +- `Generated/` is target-local +- `Prebuilt/` is global at the root + +## Minimal Behavior + +For each target from `Xcode.Project.targets`: + +1. create `Targets//` +2. create `Targets//Sources/` +3. create `Targets//Generated/` +4. create an empty `Targets//BUILD` +5. collect file entries from the target model +6. map each entry to a path relative to the project root +7. create parent directories under `Sources/` +8. create a symlink at the destination path pointing to the source path + +For root output: + +1. create output root +2. create empty root `BUILD` +3. create empty `MODULE.bazel` +4. create `Prebuilt/` +5. create empty `Prebuilt/BUILD` + +## File Selection + +The initial version should use the target file model already exposed by `Xcode2`: + +- `target.files.sources` +- `target.files.headers` +- `target.files.resources` +- `target.files.others` + +Framework and copy-files entries should be excluded from `Sources/` for this first milestone because they are closer to dependency packaging than target-owned source tree materialization. Prebuilt binary handling stays reserved for a later increment. + +## Failure Handling + +This milestone keeps failure handling simple: + +- if an entry has no usable relative path, skip it +- if the source path does not exist, skip it for now +- if the destination already exists, replace it + +The roadmap already marks missing-file behavior as deferred, so this implementation should stay minimal and deterministic rather than complete. + +## Architecture + +Keep the command thin and move tree generation into a small reusable builder. + +- `RoadmapCommand` parses CLI arguments and loads `Xcode.Project` +- `RoadmapTreeBuilder` creates directories and symlinks +- tests cover the builder output using the `fixture/iOS2` project + +## Testing + +Add a focused integration-style unit test that: + +1. loads `fixture/iOS2/Example.xcodeproj` +2. writes output into a temporary directory +3. verifies expected directories exist +4. verifies expected `BUILD` placeholders exist +5. verifies representative symlinks exist and point to the expected source paths + +## Open Choices Resolved + +- command name: `roadmap` +- output path: explicit `--output` +- generated files location: `Targets//Generated` +- prebuilt binary location: root `Prebuilt/` +- source layout: preserve original relative paths from the project root diff --git a/docs/superpowers/specs/2026-04-15-roadmap-bazelfile-design.md b/docs/superpowers/specs/2026-04-15-roadmap-bazelfile-design.md new file mode 100644 index 0000000..4922a1e --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-roadmap-bazelfile-design.md @@ -0,0 +1,120 @@ +# Roadmap Bazel File Design + +## Goal + +Extend the roadmap output so the generated workspace can move toward a real `bazel run //Example:Example` flow. + +The first milestone is focused and intentionally incomplete: + +- switch roadmap output from `Targets//...` to package-shaped directories like `/Example/...` +- generate minimal `BUILD` files for the app package and the dependency target packages it needs +- generate a minimal `MODULE.bazel` +- generate a minimal root `Package.swift` for SwiftPM integration + +This milestone is scoped to the `fixture/iOS2/Example.xcodeproj` style project and prioritizes the `Example` iOS app path. + +## Output Shape + +The output tree becomes: + +```text +/ + BUILD + MODULE.bazel + Package.swift + Prebuilt/ + BUILD + Example/ + BUILD + Sources/ + Generated/ + Framework1/ + BUILD + Sources/ + Generated/ +``` + +This package-shaped layout is required so the final target path is naturally `//Example:Example` instead of `//Targets/Example:Example`. + +## Bazel Rule Strategy + +Use the existing Bazelize rule mapping as the model: + +- application -> `ios_application` +- Swift sources -> `swift_library` +- ObjC sources -> `objc_library` +- framework target -> `ios_framework` +- static library target -> public `alias(name = "", actual = ":_library")` + +Each package should expose a public top-level target matching the package name. + +## Package-Specific Generation + +### App Package + +For `Example`: + +- generate `swift_library(name = "Example_library", ...)` +- generate `ios_application(name = "Example", ...)` +- wire target deps from `target.dependencies.targets` +- wire SwiftPM deps from `target.dependencies.packageProducts` +- wire SDK frameworks from `target.dependencies.sdkFrameworks` +- add resources from the package `Sources/` tree + +### Framework Package + +For `Framework1`, `Framework2`, `Framework3`: + +- generate the package language library target +- generate `ios_framework(name = "", ...)` +- depend on `:_library` +- depend on other target packages when needed + +### Static Library Package + +For `Static` and `Static2`: + +- generate `swift_library` or `objc_library` +- generate `alias(name = "", actual = ":_library")` + +## SwiftPM Strategy + +`Example` depends on Swift package products, so a placeholder `MODULE.bazel` is not enough. + +Generate a minimal root `Package.swift` from `Xcode.Project.packages`: + +- remotes -> `.package(url: ..., ...)` +- locals -> `.package(path: ...)` + +Generate a minimal `MODULE.bazel` with: + +- `bazel_dep` entries for `bazel_skylib`, `rules_cc`, `rules_apple`, `rules_swift`, `rules_swift_package_manager` +- `swift_deps = use_extension(...)` +- `swift_deps.from_package(...)` +- `use_repo(...)` entries derived from package repository names + +Package-product labels should follow the existing convention: + +- remote package product -> `@swiftpkg_//:` +- local package product -> `@swiftpkg_//:` + +## Deferred + +This milestone still defers: + +- tests +- prebuilt binary import rules +- xcodeproj helper rules +- full `bazel run` success verification for every fixture target +- complete missing-file policy + +## Testing + +Add tests that verify: + +- package-shaped output directories are created +- `Example/BUILD` contains `ios_application(name = "Example")` +- `Example/BUILD` contains `swift_library(name = "Example_library")` +- `Framework1/BUILD` contains `ios_framework(name = "Framework1")` +- `Static2/BUILD` contains `objc_library(name = "Static2_objc")` +- `MODULE.bazel` contains rules and SwiftPM extension wiring diff --git a/fixture/iOS/.bazelrc b/fixture/iOS/.bazelrc deleted file mode 100644 index 04c5c8f..0000000 --- a/fixture/iOS/.bazelrc +++ /dev/null @@ -1,28 +0,0 @@ -startup --batch -startup --output_user_root=/tmp/bazelize-fixture-output - -import %workspace%/config.bazelrc - -build --disk_cache=cache -# build --experimental_enable_bzlmod - -# build --apple_platform_type=ios -# build --verbose_failures -build --ios_simulator_device="iPhone 16" -test --ios_simulator_device="iPhone 16" - - -# build --macos_minimum_os=10.15 - -# # Make sure no warnings slip into the C++ tools we vendor -# build --features treat_warnings_as_errors - -# # The default strategy is worker, which has sandboxing disabled by default, -# # which can hide issues with non-hermetic bugs. -# build --strategy=SwiftCompile=sandboxed - -# # build --ios_minimum_os=15.5 -# # build --ios_simulator_device="iPhone 13" -# # build --ios_simulator_version=15.5 -# # build --xcode_version=13.4.1 -common --enable_bzlmod diff --git a/fixture/iOS/.bazelversion b/fixture/iOS/.bazelversion deleted file mode 100644 index 6d28907..0000000 --- a/fixture/iOS/.bazelversion +++ /dev/null @@ -1 +0,0 @@ -8.5.0 diff --git a/fixture/iOS/Example/Test.swift b/fixture/iOS/Example/Test.swift index dd839ca..c3cda1a 100644 --- a/fixture/iOS/Example/Test.swift +++ b/fixture/iOS/Example/Test.swift @@ -8,5 +8,5 @@ import Foundation func test() -> Int { - 0 + 0b1111 } diff --git a/fixture/iOS/ExampleTests/ExampleTests.swift b/fixture/iOS/ExampleTests/ExampleTests.swift index 7fd62c9..7230aed 100644 --- a/fixture/iOS/ExampleTests/ExampleTests.swift +++ b/fixture/iOS/ExampleTests/ExampleTests.swift @@ -10,6 +10,6 @@ import XCTest final class ExampleTests: XCTestCase { func testExample() throws { - XCTAssertEqual(test(), 0) + XCTAssertEqual(test(), 0b1111) } } diff --git a/fixture/iOS/Local1/Package.swift b/fixture/iOS/Local1/Package.swift index bb6bc52..ed0413d 100644 --- a/fixture/iOS/Local1/Package.swift +++ b/fixture/iOS/Local1/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription diff --git a/fixture/iOS/Makefile b/fixture/iOS/Makefile index 2360ab4..06bd98a 100644 --- a/fixture/iOS/Makefile +++ b/fixture/iOS/Makefile @@ -1,48 +1,42 @@ +BAZELIZE = ../../.build/debug/bazelize +OUTPUT = App + +# The simulator rules_apple runs unit tests on; override for other Xcode versions. +SIMULATOR_FLAGS = \ + --@build_bazel_rules_apple//apple/build_settings:ios_simulator_device="iPhone 17" \ + --@build_bazel_rules_apple//apple/build_settings:ios_simulator_version=27.0 + +TESTS = \ + //Targets/ExampleTests \ + //Targets/Framework1Tests \ + //Targets/Framework2Tests \ + //Targets/Framework3Tests + .PHONY: bazelize bazelize: - @bazelize --project Example.xcodeproj - @bazel mod tidy - -.PHONY: clear -clear: - @bazelize --project Example.xcodeproj --clear - -rm Package.swift - -rm Package.resolved - -rm MODULE.bazel - -rm MODULE.bazel.lock + @$(BAZELIZE) --project Example.xcodeproj --output $(OUTPUT) .PHONY: build -build: - bazel build Example - -.PHONY: updatePkg -updatePkg: - @bazel mod tidy +build: bazelize + cd $(OUTPUT) && bazel build //Targets/Example .PHONY: run -run: - bazel run --config=Debug Example +run: bazelize + cd $(OUTPUT) && bazel run --config=Debug //Targets/Example .PHONY: releaseRun -releaseRun: - bazel run --config=Release Example +releaseRun: bazelize + cd $(OUTPUT) && bazel run --config=Release //Targets/Example .PHONY: test test: - bazel test \ - --sandbox_debug \ - ExampleTests \ - Framework1Tests \ - Framework2Tests \ - Framework3Tests - + cd $(OUTPUT) && bazel test $(SIMULATOR_FLAGS) $(TESTS) .PHONY: uitest uitest: - bazel test ExampleUITests - + cd $(OUTPUT) && bazel test $(SIMULATOR_FLAGS) //Targets/ExampleUITests -# -# bazel run //:update_build_files -# bazel test //... -# --experimental_enable_bzlmod \ No newline at end of file +.PHONY: clear +clear: + @$(BAZELIZE) --project Example.xcodeproj --output $(OUTPUT) --clear + -rm -rf $(OUTPUT) diff --git a/fixture/iOS/swift_deps.bzl b/fixture/iOS/swift_deps.bzl deleted file mode 100644 index 5304c80..0000000 --- a/fixture/iOS/swift_deps.bzl +++ /dev/null @@ -1,25 +0,0 @@ -load("@cgrindel_swift_bazel//swiftpkg:defs.bzl", "local_swift_package", "swift_package") - -# Contents of swift_deps.bzl -def swift_dependencies(): - local_swift_package( - name = "swiftpkg_local1", - dependencies_index = "@//:swift_deps_index.json", - path = "Local1", - ) - - # version: 0.6.7 - swift_package( - name = "swiftpkg_anycodable", - commit = "862808b2070cd908cb04f9aafe7de83d35f81b05", - dependencies_index = "@//:swift_deps_index.json", - remote = "https://github.com/Flight-School/AnyCodable", - ) - - # version: 6.5.0 - swift_package( - name = "swiftpkg_rxswift", - commit = "b4307ba0b6425c0ba4178e138799946c3da594f8", - dependencies_index = "@//:swift_deps_index.json", - remote = "https://github.com/ReactiveX/RxSwift", - ) diff --git a/fixture/iOS/swift_deps_index.json b/fixture/iOS/swift_deps_index.json deleted file mode 100644 index 5b7ba11..0000000 --- a/fixture/iOS/swift_deps_index.json +++ /dev/null @@ -1,171 +0,0 @@ -{ - "modules": [ - { - "name": "AnyCodable", - "c99name": "AnyCodable", - "label": "@swiftpkg_anycodable//:Sources_AnyCodable" - }, - { - "name": "AnyCodableTests", - "c99name": "AnyCodableTests", - "label": "@swiftpkg_anycodable//:Tests_AnyCodableTests" - }, - { - "name": "LocalTarget1", - "c99name": "LocalTarget1", - "label": "@swiftpkg_local1//:Sources_LocalTarget1" - }, - { - "name": "LocalTarget2", - "c99name": "LocalTarget2", - "label": "@swiftpkg_local1//:Sources_LocalTarget2" - }, - { - "name": "LocalTarget3", - "c99name": "LocalTarget3", - "label": "@swiftpkg_local1//:Sources_LocalTarget3" - }, - { - "name": "Local1Tests", - "c99name": "Local1Tests", - "label": "@swiftpkg_local1//:Tests_Local1Tests" - }, - { - "name": "RxBlocking", - "c99name": "RxBlocking", - "label": "@swiftpkg_rxswift//:Sources_RxBlocking" - }, - { - "name": "RxCocoa", - "c99name": "RxCocoa", - "label": "@swiftpkg_rxswift//:Sources_RxCocoa" - }, - { - "name": "RxCocoaRuntime", - "c99name": "RxCocoaRuntime", - "label": "@swiftpkg_rxswift//:Sources_RxCocoaRuntime" - }, - { - "name": "RxRelay", - "c99name": "RxRelay", - "label": "@swiftpkg_rxswift//:Sources_RxRelay" - }, - { - "name": "RxSwift", - "c99name": "RxSwift", - "label": "@swiftpkg_rxswift//:Sources_RxSwift" - }, - { - "name": "RxTest", - "c99name": "RxTest", - "label": "@swiftpkg_rxswift//:Sources_RxTest" - } - ], - "products": [ - { - "identity": "anycodable", - "name": "AnyCodable", - "type": "library", - "target_labels": [ - "@swiftpkg_anycodable//:Sources_AnyCodable" - ] - }, - { - "identity": "local1", - "name": "LocalLib1", - "type": "library", - "target_labels": [ - "@swiftpkg_local1//:Sources_LocalTarget1", - "@swiftpkg_local1//:Sources_LocalTarget3" - ] - }, - { - "identity": "local1", - "name": "LocalLib2", - "type": "library", - "target_labels": [ - "@swiftpkg_local1//:Sources_LocalTarget2" - ] - }, - { - "identity": "rxswift", - "name": "RxBlocking", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxBlocking" - ] - }, - { - "identity": "rxswift", - "name": "RxBlocking-Dynamic", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxBlocking" - ] - }, - { - "identity": "rxswift", - "name": "RxCocoa", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxCocoa" - ] - }, - { - "identity": "rxswift", - "name": "RxCocoa-Dynamic", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxCocoa" - ] - }, - { - "identity": "rxswift", - "name": "RxRelay", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxRelay" - ] - }, - { - "identity": "rxswift", - "name": "RxRelay-Dynamic", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxRelay" - ] - }, - { - "identity": "rxswift", - "name": "RxSwift", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxSwift" - ] - }, - { - "identity": "rxswift", - "name": "RxSwift-Dynamic", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxSwift" - ] - }, - { - "identity": "rxswift", - "name": "RxTest", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxTest" - ] - }, - { - "identity": "rxswift", - "name": "RxTest-Dynamic", - "type": "library", - "target_labels": [ - "@swiftpkg_rxswift//:Sources_RxTest" - ] - } - ] -} \ No newline at end of file diff --git a/git_release.py b/git_release.py deleted file mode 100644 index 5d2ac68..0000000 --- a/git_release.py +++ /dev/null @@ -1,92 +0,0 @@ -import requests -import sys -import hashlib - -if __name__ == "__main__": - print(sys.argv) - if (len(sys.argv) < 5): - print("please input with user/repo rule_name output_file_path") - exit(1) - - repo = sys.argv[1] - name = sys.argv[2] - path = sys.argv[3] - count = sys.argv[4] - isArchive = sys.argv[5] == 'archive' - - headers = { - 'Accept': 'application/vnd.github+json' - } - # https://docs.github.com/en/rest/releases/releases - url = 'https://api.github.com/repos/{0}/releases?per_page={1}'.format(repo, count) - print(url) - r = requests.get(url, headers = headers) - - if r.status_code != 200: - r.raise_for_status() - exit(1) - - with open(path, 'w') as file: - print( -''' -extension Repo {{ - /// https://github.com/{1} - enum {0}: String {{'''.format(name, repo), file=file, end='') - - for release in r.json(): - tag = release["tag_name"] - - if "dev" in tag or "alpha" in tag or "beta" in tag: - continue - - _tag_name = tag.replace(".", "_").replace("-", "_") - tag_name = 'v{0}'.format(_tag_name) if _tag_name[:1].isdigit() else _tag_name - print( -''' - case {0} = "{1}"'''.format(tag_name, tag), file=file, end='') - - print( -''' - // MARK: Internal - - var version: String { - if rawValue.first == "v" { - return String(rawValue.dropFirst()) - } - return rawValue - } - - var sha256: String { - switch self {''', file=file, end='') - - for release in r.json(): - tag = release["tag_name"] - - if "dev" in tag or "alpha" in tag or "beta" in tag: - continue - _tag_name = tag.replace(".", "_").replace("-", "_") - tag_name = 'v{0}'.format(_tag_name) if _tag_name[:1].isdigit() else _tag_name - - tarURL = '' - if isArchive: - # v0.11.2 - tarURL = 'http://github.com/cgrindel/rules_spm/archive/{0}.tar.gz'.format(tag) - else: - tarURL = release.get("assets")[0].get("browser_download_url") - - if not tarURL is None: - print("compute sha256: {0}".format(tarURL)) - tar = requests.get(tarURL) - if tar.status_code != 200: - tar.raise_for_status() - exit(1) - print( -''' - case .{0}: return "{1}"'''.format(tag_name, hashlib.sha256(tar.content).hexdigest()), file=file, end='') - - print( -''' - } - } - } -}''', file=file, end='')