diff --git a/.github/workflows/binary_interface.yml b/.github/workflows/binary_interface.yml new file mode 100644 index 000000000..321298e5e --- /dev/null +++ b/.github/workflows/binary_interface.yml @@ -0,0 +1,63 @@ +name: Binary Interface + +on: + workflow_dispatch: + workflow_call: + inputs: + ref: + required: true + type: string + outputs: + verified-sha: + value: ${{ jobs.verify.outputs.sha }} + +permissions: + contents: read + id-token: write + +jobs: + verify: + name: Build the binary package Example + runs-on: [self-hosted, macos-15, xcode-26.6] + outputs: + sha: ${{ steps.checkout.outputs.commit }} + env: + GH_TOKEN: ${{ github.token }} + OPENSWIFTUI_USE_LOCAL_DEPS: 1 + OPENSWIFTUI_OPENATTRIBUTESHIMS_ATTRIBUTEGRAPH: 0 + OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE: 1 + OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE_BINARY: 0 + steps: + - uses: actions/checkout@v4 + id: checkout + with: + ref: ${{ inputs.ref || github.sha }} + persist-credentials: false + - uses: OpenSwiftUIProject/setup-xcode@v2 + with: + xcode-version: '26.6' + - uses: jdx/mise-action@v2 + with: + install: false + cache: false + - name: Set up dependencies + run: Scripts/CI/darwin_setup_build.sh + - name: Set up Tuist + run: | + source Scripts/Tuist/common.sh + tuist_use_mise_environment compute + tuist_ci_setup + - name: Build and verify local binary interfaces + env: + PROJECT_BUILD_DIR: ${{ runner.temp }}/binary-interface + OPENSWIFTUI_BINARY_PACKAGE_PATH: ${{ runner.temp }}/OpenSwiftUI-spm + run: Scripts/CI/check_binary_interface.sh + - name: Upload verification results + if: failure() + uses: actions/upload-artifact@v7 + with: + name: binary-interface-results + path: | + ${{ runner.temp }}/binary-interface/interface-check.*/setup.log + ${{ runner.temp }}/binary-interface/interface-check.*/build.log + ${{ runner.temp }}/binary-interface/interface-check.*/Example.xcresult diff --git a/.github/workflows/release_checks.yml b/.github/workflows/release_checks.yml index a6bf491a1..a9e1b5675 100644 --- a/.github/workflows/release_checks.yml +++ b/.github/workflows/release_checks.yml @@ -113,10 +113,16 @@ jobs: ref: ${{ needs.prepare.outputs.sha }} backend: all + binary-interface: + needs: prepare + uses: ./.github/workflows/binary_interface.yml + with: + ref: ${{ needs.prepare.outputs.sha }} + verify: name: Verify all required checks if: always() - needs: [prepare, macos, ios, visionos, ubuntu, ui, interaction, compatibility, stdout] + needs: [prepare, macos, ios, visionos, ubuntu, ui, interaction, compatibility, stdout, binary-interface] runs-on: ubuntu-latest outputs: sha: ${{ steps.verify.outputs.sha }} diff --git a/Docs/Release.md b/Docs/Release.md index cc5df1f0d..5cdef227c 100644 --- a/Docs/Release.md +++ b/Docs/Release.md @@ -32,6 +32,7 @@ By default, the release gate requires all of these checks: | interaction tests | iOS and macOS; SwiftUI, then OpenSwiftUI with the OpenSwiftUI renderer and Compute (IAG) | | Compatibility tests | iOS and macOS | | Stdout Renderer | AttributeGraph and Compute on macOS | +| Binary Interface | Build iOS Simulator arm64 XCFrameworks and compile the binary package Example from textual interfaces | Each workflow returns its checked-out commit SHA. In the default mode, a failed, cancelled, skipped, or missing check cannot pass the gate. Snapshot checks @@ -192,6 +193,34 @@ Binary Integration section. A manual theme still applies; otherwise the current title stays unchanged, which can leave a new release with only its version. Invalid manual themes fail validation instead of silently falling back. +## Check Binary Interfaces Locally + +Run the same consumer check used by the release gate: + +```shell +Scripts/CI/check_binary_interface.sh +``` + +This builds all seven release XCFrameworks for iOS Simulator arm64 with Compute, +then builds the `OpenSwiftUI-spm/Example` app. It does not boot a simulator. +The consumer prefers textual interfaces, even with the same compiler as the +producer, and uses fresh DerivedData to avoid cached modules hiding errors. + +Before packaging and final signing, the build normalizes duplicate platform +introductions in all public, private, and package Swift interfaces. For each +platform, it keeps the highest version in a short-form `@available` attribute. +Long-form attributes, including `unavailable` and `deprecated`, remain unchanged. +This preserves source availability macros while working around +[swiftlang/swift#93415](https://github.com/swiftlang/swift/issues/93415). + +The default output directory is `../build`. Set `PROJECT_BUILD_DIR` to change it. +The script uses `../OpenSwiftUI-spm`, or clones it with `gh` when absent. Set +`OPENSWIFTUI_BINARY_PACKAGE_PATH` to use another checkout. The script passes +`OPENSWIFTUI_LOCAL_XCFRAMEWORKS_PATH` to the package without editing its manifest, +and rejects missing local frameworks instead of downloading published binaries. +Use `--skip-build` to verify existing output. Logs and the Example result bundle +are retained in a new `interface-check.*` directory under the build directory. + ## Skip Pre-release Checks Set `skip_checks=true` to bypass all pre-release test workflows for one diff --git a/Scripts/CI/Tests/availability-interface-tests.js b/Scripts/CI/Tests/availability-interface-tests.js new file mode 100644 index 000000000..9b93604b0 --- /dev/null +++ b/Scripts/CI/Tests/availability-interface-tests.js @@ -0,0 +1,124 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { normalizeText, normalizeDirectory } = require("../../normalize_swiftinterface.js"); + +const root = path.resolve(__dirname, "../../.."); + +test("duplicate platform versions use the strictest numeric version in either order", () => { + for (const versions of ["macOS 10.9, macOS 10.15.1", "macOS 10.15.1, macOS 10.9"]) { + const source = ` @available(iOS 13.0, ${versions}, iOS 15.0, visionOS 1.0, *)\n`; + const expected = " @available(iOS 15.0, macOS 10.15.1, visionOS 1.0, *)\n"; + assert.equal(normalizeText(source), expected); + assert.equal(normalizeText(expected), expected); + } + assert.equal(normalizeText("@available(iOS 13, iOS 13.0, *)"), "@available(iOS 13, *)"); +}); + +test("complex attributes, comments, and strings remain unchanged", () => { + const source = [ + '@available(iOS, introduced: 13.0, deprecated: 15.0, message: "Use another API")', + '@available(macOS, unavailable)', + '@available(iOS 13.0, iOS 15.0, *, deprecated)', + '@available(*, deprecated, renamed: "replacement()")', + '@available(swift 6.0)', + '@available(iOS 13.0, macOS 10.15, *)', + '// @available(iOS 13.0, iOS 15.0, *)', + '/* Outer /* Nested */ @available(iOS 13.0, iOS 15.0, *) */', + 'public let text = #"""', + '@available(iOS 13.0, iOS 15.0, *)', + '"""#', + 'public let escaped = "\\\"@available(iOS 13.0, iOS 15.0, *)"', + 'public let interpolated = "\\(value("@available(iOS 13.0, iOS 15.0, *)"))"', + ].join("\n"); + assert.equal(normalizeText(source), source); + assert.equal(normalizeText(source + '\n@available(iOS 13.0, iOS 15.0, *)'), + source + '\n@available(iOS 15.0, *)'); +}); + +test("normalizes all interface variants without touching binary modules", async t => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "interface files ")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const modules = path.join(directory, "Probe.swiftmodule"); + await fs.mkdir(modules); + const source = "@available(iOS 13.0, iOS 15.0, *)\n"; + for (const suffix of ["swiftinterface", "private.swiftinterface", "package.swiftinterface", "swiftmodule"]) { + await fs.writeFile(path.join(modules, `arm64.${suffix}`), source); + } + // macOS frameworks expose Modules through a Versions/Current symlink. + const current = path.join(directory, "Current"); + await fs.symlink(modules, current); + assert.equal(normalizeDirectory(current), 3); + for (const suffix of ["swiftinterface", "private.swiftinterface", "package.swiftinterface"]) { + assert.equal(await fs.readFile(path.join(modules, `arm64.${suffix}`), "utf8"), "@available(iOS 15.0, *)\n"); + } + assert.equal(await fs.readFile(path.join(modules, "arm64.swiftmodule"), "utf8"), source); + assert.equal(normalizeDirectory(directory), 0); + assert.throws(() => normalizeDirectory(path.join(directory, "missing")), /ENOENT/); +}); + +test("normalized source availability configurations can be loaded as interfaces", { + skip: process.platform !== "darwin", +}, async t => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "availability-interface-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const blocks = new Set(); + for (const file of [ + "Sources/OpenSwiftUICore/Data/EnvironmentKeys/ControlSize.swift", + "Sources/OpenSwiftUI/Event/Focus/DefaultFocus.swift", + ]) { + const source = await fs.readFile(path.join(root, file), "utf8"); + for (const match of source.matchAll(/(?:^[ \t]*@available\([^\n]+\)\n)+/gm)) blocks.add(match[0].trim()); + } + assert.ok(blocks.size > 0); + const fixture = path.join(directory, "Probe.swift"); + await fs.writeFile(fixture, [...blocks].map((block, index) => + `${block}\npublic struct Probe${index} {}\n`).join("\n") + + "\n@available(macOS 11.0, *)\n@available(macOS 15.0, *)\npublic func api() {}\n"); + const manifest = await fs.readFile(path.join(root, "Package.swift"), "utf8"); + const minimum = manifest.match(/let minimumVersion = "([^"]+)"/)[1]; + const definitions = [...manifest.matchAll(/AvailabilityMacro=(\w+):\\\(ignoreAvailability \? minimumVersion : "([^"]+)"\)/g)]; + assert.ok(definitions.length > 0); + const configurations = { + package: definitions.map(([, name]) => `${name}:${minimum}`), + compatibility: definitions.map(([, name, versions]) => `${name}:${versions}`), + }; + for (const name of ["OpenSwiftUI", "SwiftUI"]) { + const config = await fs.readFile(path.join(root, `Configurations/Shared/basic/${name}.xcconfig`), "utf8"); + configurations[name] = [...config.matchAll(/"AvailabilityMacro=([^"]+)"/g)].map(match => match[1]); + } + for (const [name, macros] of Object.entries(configurations)) { + await t.test(name, async () => { + const output = path.join(directory, name); + await fs.mkdir(output); + const base = ["swiftc", "-swift-version", "6", "-target", "arm64-apple-macosx11.0", + "-module-cache-path", path.join(output, "cache")]; + const result = spawnSync("xcrun", [...base, fixture, "-emit-module", "-enable-library-evolution", + "-no-verify-emitted-module-interface", "-module-name", "Probe", "-package-name", "ProbePackage", + "-emit-module-path", path.join(output, "Probe.swiftmodule"), + "-emit-module-interface-path", path.join(output, "Probe.swiftinterface"), + "-emit-package-module-interface-path", path.join(output, "Probe.package.swiftinterface"), + ...macros.flatMap(macro => ["-enable-experimental-feature", `AvailabilityMacro=${macro}`]), + ], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + normalizeDirectory(output); + // Import each variant on its own so serialized modules and other variants cannot hide errors. + for (const variant of ["swiftinterface", "private.swiftinterface", "package.swiftinterface"]) { + const load = path.join(output, variant); + await fs.mkdir(load); + await fs.copyFile(path.join(output, `Probe.${variant}`), path.join(load, "Probe.swiftinterface")); + const client = path.join(load, "Client.swift"); + for (const version of ["13.0", "15.0"]) { + await fs.writeFile(client, `import Probe\n@available(macOS ${version}, *)\nfunc caller() { api() }\n`); + const check = spawnSync("xcrun", [...base, "-typecheck", client, "-I", load, + "-Xfrontend", "-module-load-mode", "-Xfrontend", "prefer-interface"], { encoding: "utf8" }); + assert.equal(check.status, version === "13.0" ? 1 : 0, check.stderr); + if (version === "13.0") assert.match(check.stderr, /'api\(\)' is only available in macOS 15.0 or newer/); + } + } + }); + } +}); diff --git a/Scripts/CI/Tests/binary-interface-tests.js b/Scripts/CI/Tests/binary-interface-tests.js new file mode 100644 index 000000000..70b525837 --- /dev/null +++ b/Scripts/CI/Tests/binary-interface-tests.js @@ -0,0 +1,71 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const frameworks = require("../release.js").frameworks; + +async function fixture(t, { missing, buildStatus = 0, consumerStatus = 0 } = {}) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "binary interface ")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const source = path.join(root, "OpenSwiftUI"); + const output = path.join(root, "build"); + const binary = path.join(root, "OpenSwiftUI-spm"); + await fs.mkdir(path.join(source, "Scripts/CI"), { recursive: true }); + await fs.mkdir(path.join(binary, "Example"), { recursive: true }); + await fs.mkdir(path.join(root, "bin")); + await fs.copyFile(path.join(__dirname, "../check_binary_interface.sh"), path.join(source, "Scripts/CI/check_binary_interface.sh")); + await fs.writeFile(path.join(source, "Scripts/build_xcframework.sh"), `#!/bin/bash +printf '%s\\n' "$@" > "$TEST_ROOT/build-args" +exit ${buildStatus} +`, { mode: 0o755 }); + await fs.writeFile(path.join(binary, "Example/setup.sh"), "#!/bin/bash\nexit 0\n", { mode: 0o755 }); + await fs.writeFile(path.join(root, "bin/xcodebuild"), `#!/bin/bash +printf '%s\\n' "$@" > "$TEST_ROOT/consumer-args" +printf '%s' "$OPENSWIFTUI_LOCAL_XCFRAMEWORKS_PATH" > "$TEST_ROOT/local-path" +exit ${consumerStatus} +`, { mode: 0o755 }); + for (const name of frameworks.filter(name => name !== missing)) { + await fs.mkdir(path.join(output, `${name}.xcframework`), { recursive: true }); + } + return { + root, output, + run: (...args) => spawnSync("bash", [path.join(source, "Scripts/CI/check_binary_interface.sh"), ...args], { + encoding: "utf8", env: { ...process.env, PATH: `${root}/bin:${process.env.PATH}`, TEST_ROOT: root, + PROJECT_BUILD_DIR: output, OPENSWIFTUI_BINARY_PACKAGE_PATH: binary }, + }), + }; +} + +test("builds one simulator architecture and loads the local textual interfaces", async t => { + const f = await fixture(t); + const result = f.run(); + assert.equal(result.status, 0, result.stderr); + const build = await fs.readFile(path.join(f.root, "build-args"), "utf8"); + assert.match(build, /--sdk\niphonesimulator\n--archs\narm64/); + const consumer = await fs.readFile(path.join(f.root, "consumer-args"), "utf8"); + assert.match(consumer, /generic\/platform=iOS Simulator/); + assert.match(consumer, /-module-load-mode.*prefer-interface/); + assert.equal(await fs.readFile(path.join(f.root, "local-path"), "utf8"), await fs.realpath(f.output)); +}); + +test("a missing local framework fails before the package can download a release", async t => { + const f = await fixture(t, { missing: "OpenSwiftUICore" }); + const result = f.run("--skip-build"); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /OpenSwiftUICore.xcframework/); + await assert.rejects(fs.stat(path.join(f.root, "consumer-args")), { code: "ENOENT" }); +}); + +test("a packaging failure cannot pass the check", async t => { + const f = await fixture(t, { buildStatus: 42 }); + assert.equal(f.run().status, 42); + await assert.rejects(fs.stat(path.join(f.root, "consumer-args")), { code: "ENOENT" }); +}); + +test("a consumer compile failure cannot pass the check", async t => { + const f = await fixture(t, { consumerStatus: 65 }); + assert.equal(f.run("--skip-build").status, 65); +}); diff --git a/Scripts/CI/Tests/workflow-tests.js b/Scripts/CI/Tests/workflow-tests.js index cc838645b..455b273e7 100644 --- a/Scripts/CI/Tests/workflow-tests.js +++ b/Scripts/CI/Tests/workflow-tests.js @@ -32,7 +32,7 @@ async function script(step, env, context = {}, github = {}, observations = {}) { test("pre-release covers every check family at one SHA and all optional configurations", () => { const { jobs } = workflows.release_checks; - assert.deepEqual(release.requiredChecks, ["macos", "ios", "visionos", "ubuntu", "ui", "interaction", "compatibility", "stdout"]); + assert.deepEqual(release.requiredChecks, ["macos", "ios", "visionos", "ubuntu", "ui", "interaction", "compatibility", "stdout", "binary-interface"]); assert.deepEqual(jobs.verify.needs, ["prepare", ...release.requiredChecks]); assert.equal(jobs.verify.if, "always()"); for (const name of release.requiredChecks) { diff --git a/Scripts/CI/check_binary_interface.sh b/Scripts/CI/check_binary_interface.sh new file mode 100755 index 000000000..1fa22bc89 --- /dev/null +++ b/Scripts/CI/check_binary_interface.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SOURCE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" +PACKAGE_ROOT="${OPENSWIFTUI_BINARY_PACKAGE_PATH:-$SOURCE_ROOT/../OpenSwiftUI-spm}" +BUILD_ROOT="${PROJECT_BUILD_DIR:-$SOURCE_ROOT/../build}" +skip_build=false + +for argument in "$@"; do + case "$argument" in + --skip-build) skip_build=true ;; + --help|-h) + cat <<'USAGE' +Usage: Scripts/CI/check_binary_interface.sh [--skip-build] + +Build all release XCFrameworks for iOS Simulator arm64, then compile the +OpenSwiftUI-spm Example against their textual interfaces. No simulator boots. + +PROJECT_BUILD_DIR sets the build directory (default: ../build). +OPENSWIFTUI_BINARY_PACKAGE_PATH selects the consumer checkout +(default: ../OpenSwiftUI-spm; cloned with gh if absent). +--skip-build verifies existing XCFrameworks without rebuilding them. +Each verification uses fresh DerivedData and keeps its logs and xcresult. +USAGE + exit 0 ;; + *) echo "Unknown argument: $argument" >&2; exit 64 ;; + esac +done + +mkdir -p "$BUILD_ROOT" +BUILD_ROOT="$(cd "$BUILD_ROOT" && pwd -P)" +export PROJECT_BUILD_DIR="$BUILD_ROOT" + +if [[ "$skip_build" == false ]]; then + export OPENSWIFTUI_OPENATTRIBUTESHIMS_ATTRIBUTEGRAPH=0 + export OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE=1 + export OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE_BINARY=0 + "$SOURCE_ROOT/Scripts/build_xcframework.sh" --compute \ + --sdk iphonesimulator --archs arm64 --keep-derived-data +fi + +# The package falls back to release URLs for missing local frameworks. +# Reject incomplete output so a published binary cannot hide a candidate bug. +for framework in OpenSwiftUI OpenSwiftUICore OpenAttributeGraphShims \ + OpenCoreGraphicsShims OpenObservation OpenQuartzCoreShims OpenRenderBoxShims; do + if [[ ! -d "$BUILD_ROOT/$framework.xcframework" ]]; then + echo "Missing local framework: $BUILD_ROOT/$framework.xcframework" >&2 + exit 1 + fi +done + +if [[ ! -e "$PACKAGE_ROOT" ]]; then + gh repo clone OpenSwiftUIProject/OpenSwiftUI-spm "$PACKAGE_ROOT" +fi +PACKAGE_ROOT="$(cd "$PACKAGE_ROOT" && pwd -P)" +export OPENSWIFTUI_LOCAL_XCFRAMEWORKS_PATH="$BUILD_ROOT" +verification_root="$(mktemp -d "$BUILD_ROOT/interface-check.XXXXXX")" +echo "Binary package: $PACKAGE_ROOT" +echo "Local XCFrameworks: $OPENSWIFTUI_LOCAL_XCFRAMEWORKS_PATH" +echo "Verification results: $verification_root" + +cd "$PACKAGE_ROOT/Example" +./setup.sh 2>&1 | tee "$verification_root/setup.log" +# Prefer interfaces even when the producer and consumer use the same compiler. +# Fresh DerivedData prevents a cached compiled module from hiding interface errors. +xcodebuild build \ + -workspace Example.xcworkspace \ + -scheme Example-Workspace \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "$verification_root/DerivedData" \ + -resultBundlePath "$verification_root/Example.xcresult" \ + ARCHS=arm64 ONLY_ACTIVE_ARCH=YES CODE_SIGNING_ALLOWED=NO \ + 'OTHER_SWIFT_FLAGS=$(inherited) -Xfrontend -module-load-mode -Xfrontend prefer-interface' \ + 2>&1 | tee "$verification_root/build.log" diff --git a/Scripts/CI/release.js b/Scripts/CI/release.js index 9b75fd9a5..8affb5295 100644 --- a/Scripts/CI/release.js +++ b/Scripts/CI/release.js @@ -7,7 +7,7 @@ const frameworks = [ "OpenSwiftUI", "OpenSwiftUICore", "OpenAttributeGraphShims", "OpenCoreGraphicsShims", "OpenObservation", "OpenQuartzCoreShims", "OpenRenderBoxShims", ]; -const requiredChecks = ["macos", "ios", "visionos", "ubuntu", "ui", "interaction", "compatibility", "stdout"]; +const requiredChecks = ["macos", "ios", "visionos", "ubuntu", "ui", "interaction", "compatibility", "stdout", "binary-interface"]; const manifestName = "release-manifest.json"; function validateVersion(version) { diff --git a/Scripts/build_xcframework.sh b/Scripts/build_xcframework.sh index d5be1e013..6a0bbfc3b 100755 --- a/Scripts/build_xcframework.sh +++ b/Scripts/build_xcframework.sh @@ -569,6 +569,7 @@ build_framework() { fi flatten_framework_resource_bundles "$scheme" "$framework" + node "$SCRIPT_DIR/normalize_swiftinterface.js" "$modules_path" strip_release_metadata "$modules_path" } diff --git a/Scripts/normalize_swiftinterface.js b/Scripts/normalize_swiftinterface.js new file mode 100644 index 000000000..674f36941 --- /dev/null +++ b/Scripts/normalize_swiftinterface.js @@ -0,0 +1,128 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +// Work around https://github.com/swiftlang/swift/issues/93415 until the +// interface printer merges repeated platform introductions itself. +function compareVersions(left, right) { + const a = left.split(".").map(BigInt); + const b = right.split(".").map(BigInt); + for (let i = 0; i < Math.max(a.length, b.length); i++) { + if ((a[i] ?? 0n) !== (b[i] ?? 0n)) return (a[i] ?? 0n) > (b[i] ?? 0n) ? 1 : -1; + } + return 0; +} + +function normalizeAttribute(attribute) { + const entries = attribute.slice("@available(".length, -1).split(",").map(entry => entry.trim()); + if (entries.pop() !== "*") return attribute; + const platforms = new Map(); + let duplicate = false; + for (const entry of entries) { + const match = /^([A-Za-z_][A-Za-z_0-9]*)\s+(\d+(?:\.\d+)*)$/.exec(entry); + // Long-form attributes and unknown syntax must remain unchanged. + if (!match) return attribute; + const [, platform, version] = match; + if (platforms.has(platform)) { + duplicate = true; + if (compareVersions(version, platforms.get(platform)) <= 0) continue; + } + platforms.set(platform, version); + } + if (!duplicate) return attribute; + return `@available(${[...platforms].map(([platform, version]) => `${platform} ${version}`).join(", ")}, *)`; +} + +// Ignore comments and string literals, including raw/multiline strings and +// nested interpolation, so examples and default argument values are preserved. +function nonCodeEnd(text, start) { + if (text.startsWith("//", start)) { + const end = text.indexOf("\n", start); + return end < 0 ? text.length : end; + } + if (text.startsWith("/*", start)) { + let depth = 1; + let i = start + 2; + while (i < text.length && depth) { + if (text.startsWith("/*", i)) { depth++; i += 2; } + else if (text.startsWith("*/", i)) { depth--; i += 2; } + else i++; + } + return i; + } + if (text[start] !== '"' && text[start] !== "#") return start; + const opening = /^(#*)("""|")/.exec(text.slice(start)); + if (!opening) return start; + const closing = opening[2] + opening[1]; + const escape = "\\" + opening[1]; + let i = start + opening[0].length; + while (i < text.length) { + if (text.startsWith(closing, i)) return i + closing.length; + if (text.startsWith(escape, i)) { + i += escape.length; + if (text[i] === "(") { + let depth = 1; + i++; + while (i < text.length && depth) { + const end = nonCodeEnd(text, i); + if (end > i) { i = end; continue; } + if (text[i] === "(") depth++; + if (text[i] === ")") depth--; + i++; + } + } else i++; + } else i++; + } + return i; +} + +function normalizeText(text) { + let result = ""; + let copied = 0; + for (let i = 0; i < text.length;) { + const end = nonCodeEnd(text, i); + if (end > i) { i = end; continue; } + if (text.startsWith("@available(", i)) { + const close = text.indexOf(")", i); + if (close >= 0) { + const attribute = text.slice(i, close + 1); + const normalized = normalizeAttribute(attribute); + if (normalized !== attribute) { + result += text.slice(copied, i) + normalized; + i = copied = close + 1; + continue; + } + } + } + i++; + } + return result + text.slice(copied); +} + +function normalizeDirectory(directory) { + let changed = 0; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) changed += normalizeDirectory(file); + else if (entry.isFile() && entry.name.endsWith(".swiftinterface")) { + const original = fs.readFileSync(file, "utf8"); + const normalized = normalizeText(original); + if (normalized !== original) { + fs.writeFileSync(file, normalized); + changed++; + } + } + } + return changed; +} + +if (require.main === module) { + if (process.argv.length !== 3) { + console.error("Usage: node Scripts/normalize_swiftinterface.js "); + process.exitCode = 64; + } else { + const count = normalizeDirectory(process.argv[2]); + console.log(`Normalized availability in ${count} Swift interfaces.`); + } +} + +module.exports = { normalizeText, normalizeDirectory };