diff --git a/.github/scripts/generate-public-api-list.sh b/.github/scripts/generate-public-api-list.sh new file mode 100644 index 00000000..d31f1637 --- /dev/null +++ b/.github/scripts/generate-public-api-list.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +mode="--write" +if [[ "${1:-}" == "--check" ]]; then + mode="--check" +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +repo_root="$(cd "${script_dir}/../.." && pwd -P)" + +if [[ ! -f "${repo_root}/Package.swift" ]]; then + echo "error: Could not resolve repository root from script location." >&2 + exit 1 +fi +symbol_graph_dir="${repo_root}/.build/public-api-symbol-graphs" +apis_path="${repo_root}/APIs.md" +cd "${repo_root}" + +rm -rf "${symbol_graph_dir}" +mkdir -p "${symbol_graph_dir}" + +if ! command -v swift >/dev/null 2>&1; then + echo "error: Swift is required to generate the public API list." >&2 + exit 1 +fi + +help_text="$(swift package dump-symbol-graph -help 2>&1 || true)" + +dump_command=(swift package dump-symbol-graph --minimum-access-level public) + +if grep -q -- "--target" <<< "${help_text}"; then + dump_command+=(--target SwiftUIBackports) +fi + +if grep -q -- "--output-path" <<< "${help_text}"; then + dump_command+=(--output-path "${symbol_graph_dir}") + "${dump_command[@]}" +else + before_list="$(mktemp)" + after_list="$(mktemp)" + new_list="$(mktemp)" + cleanup() { + rm -f "${before_list}" "${after_list}" "${new_list}" + } + trap cleanup EXIT + + find "${repo_root}/.build" -type f -name "*.symbols.json" 2>/dev/null | sort > "${before_list}" || true + + "${dump_command[@]}" + + find "${repo_root}/.build" -type f -name "*.symbols.json" 2>/dev/null | sort > "${after_list}" || true + + comm -13 "${before_list}" "${after_list}" > "${new_list}" || true + + if [[ -s "${new_list}" ]]; then + while IFS= read -r symbol_file; do + cp "${symbol_file}" "${symbol_graph_dir}/" + done < "${new_list}" + elif [[ -s "${after_list}" ]]; then + while IFS= read -r symbol_file; do + cp "${symbol_file}" "${symbol_graph_dir}/" + done < "${after_list}" + else + echo "error: dump-symbol-graph did not produce any .symbols.json files" >&2 + exit 1 + fi +fi + +swift "${repo_root}/.github/scripts/generate_public_api_list.swift" \ + --symbol-graphs-dir "${symbol_graph_dir}" \ + --apis-file "${apis_path}" \ + "${mode}" diff --git a/.github/scripts/generate_public_api_list.swift b/.github/scripts/generate_public_api_list.swift new file mode 100644 index 00000000..63a81709 --- /dev/null +++ b/.github/scripts/generate_public_api_list.swift @@ -0,0 +1,525 @@ +import Foundation + +private let generatorNote = "This file is auto-generated by `.github/scripts/generate-public-api-list.sh`." + +private struct SymbolEntry { + let precise: String + let group: String + let title: String + let signature: String + var availabilityByDomain: [String: String] +} + +private enum Mode { + case write + case check +} + +private struct Configuration { + let symbolGraphsDirectory: URL + let apiFilePath: URL + let mode: Mode +} + +private func fail(_ message: String) -> Never { + fputs("error: \(message)\n", stderr) + exit(1) +} + +private func parseArguments() -> Configuration { + var arguments = Array(CommandLine.arguments.dropFirst()) + var symbolGraphsDirectory: URL? + var apiFilePath: URL? + var mode: Mode = .write + + while !arguments.isEmpty { + let argument = arguments.removeFirst() + switch argument { + case "--symbol-graphs-dir": + guard !arguments.isEmpty else { + fail("Missing value for --symbol-graphs-dir") + } + symbolGraphsDirectory = URL(fileURLWithPath: arguments.removeFirst()) + case "--apis-file": + guard !arguments.isEmpty else { + fail("Missing value for --apis-file") + } + apiFilePath = URL(fileURLWithPath: arguments.removeFirst()) + case "--check": + mode = .check + case "--write": + mode = .write + default: + fail("Unknown argument: \(argument)") + } + } + + guard let symbolGraphsDirectory else { + fail("Argument --symbol-graphs-dir is required") + } + + guard let apiFilePath else { + fail("Argument --apis-file is required") + } + + return Configuration( + symbolGraphsDirectory: symbolGraphsDirectory, + apiFilePath: apiFilePath, + mode: mode + ) +} + +private func fileURLs(in directory: URL) -> [URL] { + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { + return [] + } + + return enumerator.compactMap { item in + guard let url = item as? URL else { return nil } + return url.pathExtension == "json" ? url : nil + } +} + +private func string(from declarationFragments: Any?) -> String? { + guard let fragments = declarationFragments as? [[String: Any]] else { + return nil + } + + let output = fragments.compactMap { fragment -> String? in + fragment["spelling"] as? String + }.joined() + + return output.isEmpty ? nil : output +} + +private func normalizedWhitespace(_ string: String) -> String { + string + .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) +} + +private func normalizedSignature(_ signature: String) -> String { + var result = normalizedWhitespace(signature) + + while result.hasPrefix("public ") || result.hasPrefix("open ") { + if result.hasPrefix("public ") { + result.removeFirst("public ".count) + } else if result.hasPrefix("open ") { + result.removeFirst("open ".count) + } + result = normalizedWhitespace(result) + } + + return result +} + +private func fallbackSignature(kindIdentifier: String, title: String) -> String { + if kindIdentifier.contains("func") { + return "func \(title)" + } + if kindIdentifier.contains("property") || kindIdentifier.contains("var") { + return "var \(title)" + } + if kindIdentifier.contains("enum") { + return "enum \(title)" + } + if kindIdentifier.contains("struct") { + return "struct \(title)" + } + if kindIdentifier.contains("class") { + return "class \(title)" + } + if kindIdentifier.contains("protocol") { + return "protocol \(title)" + } + if kindIdentifier.contains("typealias") { + return "typealias \(title)" + } + if kindIdentifier.contains("init") { + return "init\(title)" + } + return title +} + +private func versionString(from introduced: [String: Any]) -> String? { + guard let major = introduced["major"] as? Int else { + return nil + } + let minor = introduced["minor"] as? Int ?? 0 + let patch = introduced["patch"] as? Int ?? 0 + + if patch > 0 { + return "\(major).\(minor).\(patch)" + } + if minor > 0 { + return "\(major).\(minor)" + } + return "\(major)" +} + +private func versionComponents(_ version: String) -> [Int] { + let components = version.split(separator: ".").map { Int($0) ?? 0 } + if components.count >= 3 { + return Array(components.prefix(3)) + } + if components.count == 2 { + return [components[0], components[1], 0] + } + if components.count == 1 { + return [components[0], 0, 0] + } + return [0, 0, 0] +} + +private func lowerVersion(_ lhs: String, _ rhs: String) -> String { + let left = versionComponents(lhs) + let right = versionComponents(rhs) + if left.lexicographicallyPrecedes(right) { + return lhs + } + return rhs +} + +private func availabilityByDomain(from availability: Any?) -> [String: String] { + guard let availabilityEntries = availability as? [[String: Any]] else { + return [:] + } + + var output: [String: String] = [:] + let supportedDomains = Set(["iOS", "tvOS", "watchOS", "macOS"]) + + for entry in availabilityEntries { + guard let domain = entry["domain"] as? String, supportedDomains.contains(domain) else { + continue + } + guard let introduced = entry["introduced"] as? [String: Any], let version = versionString(from: introduced) else { + continue + } + + if let existing = output[domain] { + output[domain] = lowerVersion(existing, version) + } else { + output[domain] = version + } + } + + return output +} + +private func isBackportFacing(pathComponents: [String], title: String, signature: String, precise: String) -> Bool { + if pathComponents.contains("Backport") { + return true + } + if pathComponents.contains("EnvironmentValues") { + return true + } + if precise.contains("Backport") { + return true + } + if signature.contains("Backport<") { + return true + } + if title == "backport" || title.hasPrefix("backport") { + return true + } + if let last = pathComponents.last, last == "backport" || last.hasPrefix("backport") { + return true + } + return false +} + +private func groupName(pathComponents: [String], title: String, signature: String, kindIdentifier: String) -> String { + if pathComponents.contains("EnvironmentValues") || (title.hasPrefix("backport") && signature.contains("EnvironmentValues")) { + return "Environment Backports" + } + if title == "backport", pathComponents.contains("View") { + return "View Namespace" + } + if title == "backport", pathComponents.contains("AnyTransition") { + return "Transition Namespace" + } + if pathComponents.contains("Backport") { + if kindIdentifier.contains("func") + || kindIdentifier.contains("subscript") + || signature.hasPrefix("func ") + || signature.hasPrefix("subscript") + { + return "Modifiers" + } + return "Backport Namespace" + } + return "Other Backport API" +} + +private func isDeclaredInSwiftUIBackportsSources(_ symbol: [String: Any]) -> Bool { + func isBackportsSourceURI(_ uri: String) -> Bool { + uri.contains("/Sources/SwiftUIBackports/") || uri.contains("\\Sources\\SwiftUIBackports\\") + } + + if let location = symbol["location"] as? [String: Any], + let uri = location["uri"] as? String, + isBackportsSourceURI(uri) + { + return true + } + + if let locations = symbol["locations"] as? [[String: Any]] { + for location in locations { + if let uri = location["uri"] as? String, isBackportsSourceURI(uri) { + return true + } + } + } + + return false +} + +private func loadSymbolEntries(from file: URL) -> [SymbolEntry] { + guard let data = try? Data(contentsOf: file) else { + fail("Unable to read symbol graph file at \(file.path)") + } + + guard let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + fail("Unable to decode JSON in \(file.path)") + } + + let moduleName = ((raw["module"] as? [String: Any])?["name"] as? String) ?? "" + guard moduleName == "SwiftUIBackports" else { + return [] + } + + let symbolObjects: [[String: Any]] + if let symbols = raw["symbols"] as? [[String: Any]] { + symbolObjects = symbols + } else if let symbolsByIdentifier = raw["symbols"] as? [String: Any] { + symbolObjects = symbolsByIdentifier.compactMap { $0.value as? [String: Any] } + } else { + return [] + } + + var output: [SymbolEntry] = [] + + for symbol in symbolObjects { + + guard let accessLevel = symbol["accessLevel"] as? String, + accessLevel == "public" || accessLevel == "open" + else { + continue + } + + guard let identifier = symbol["identifier"] as? [String: Any], + let precise = identifier["precise"] as? String + else { + continue + } + + guard isDeclaredInSwiftUIBackportsSources(symbol) else { + continue + } + + let names = symbol["names"] as? [String: Any] + let title = (names?["title"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let pathComponents = symbol["pathComponents"] as? [String] ?? [] + + let kind = symbol["kind"] as? [String: Any] + let kindIdentifier = kind?["identifier"] as? String ?? "" + + let declaration = string(from: symbol["declarationFragments"]) + ?? string(from: names?["subHeading"]) + ?? fallbackSignature(kindIdentifier: kindIdentifier, title: title) + + let signature = normalizedSignature(declaration) + guard !signature.isEmpty else { continue } + + guard isBackportFacing( + pathComponents: pathComponents, + title: title, + signature: signature, + precise: precise + ) else { + continue + } + + let availability = availabilityByDomain(from: symbol["availability"]) + + output.append( + SymbolEntry( + precise: precise, + group: groupName( + pathComponents: pathComponents, + title: title, + signature: signature, + kindIdentifier: kindIdentifier + ), + title: title, + signature: signature, + availabilityByDomain: availability + ) + ) + } + + return output +} + +private func mergedEntries(_ entries: [SymbolEntry]) -> [SymbolEntry] { + var byPrecise: [String: SymbolEntry] = [:] + + for entry in entries { + if var existing = byPrecise[entry.precise] { + for (domain, version) in entry.availabilityByDomain { + if let current = existing.availabilityByDomain[domain] { + existing.availabilityByDomain[domain] = lowerVersion(current, version) + } else { + existing.availabilityByDomain[domain] = version + } + } + + if entry.signature.count > existing.signature.count { + byPrecise[entry.precise] = SymbolEntry( + precise: existing.precise, + group: existing.group, + title: existing.title, + signature: entry.signature, + availabilityByDomain: existing.availabilityByDomain + ) + } else { + byPrecise[entry.precise] = existing + } + } else { + byPrecise[entry.precise] = entry + } + } + + return Array(byPrecise.values) +} + +private func availabilityLine(for availabilityByDomain: [String: String]) -> String? { + let domainOrder = ["iOS", "tvOS", "watchOS", "macOS"] + let parts = domainOrder.compactMap { domain -> String? in + guard let version = availabilityByDomain[domain] else { return nil } + return "\(domain) \(version)" + } + + guard !parts.isEmpty else { + return nil + } + + return "@available(\(parts.joined(separator: ", ")), *)" +} + +private func generatedDocument(from entries: [SymbolEntry]) -> String { + let groupOrder = [ + "View Namespace", + "Transition Namespace", + "Environment Backports", + "Modifiers", + "Backport Namespace", + "Other Backport API" + ] + + let grouped = Dictionary(grouping: entries, by: { $0.group }) + let allGroups = Array(grouped.keys) + let sortedGroups = allGroups.sorted { lhs, rhs in + let left = groupOrder.firstIndex(of: lhs) ?? Int.max + let right = groupOrder.firstIndex(of: rhs) ?? Int.max + if left == right { return lhs < rhs } + return left < right + } + + var lines: [String] = [ + "# Available APIs", + "", + generatorNote, + "", + "## Public API", + "" + ] + + var hasAtLeastOneEntry = false + for group in sortedGroups { + guard let symbols = grouped[group], !symbols.isEmpty else { + continue + } + hasAtLeastOneEntry = true + + lines.append("### \(group)") + lines.append("") + + let orderedSymbols = symbols.sorted { + if $0.title == $1.title { + if $0.signature == $1.signature { + return $0.precise < $1.precise + } + return $0.signature < $1.signature + } + return $0.title < $1.title + } + + for symbol in orderedSymbols { + if let availability = availabilityLine(for: symbol.availabilityByDomain) { + lines.append("- `\(availability)` ") + lines.append(" `\(symbol.signature)`") + } else { + lines.append("- `\(symbol.signature)`") + } + } + + lines.append("") + } + + if !hasAtLeastOneEntry { + lines.append("_No API entries generated._") + } + + return lines.joined(separator: "\n") +} + +private func run() { + let configuration = parseArguments() + let symbolGraphFiles = fileURLs(in: configuration.symbolGraphsDirectory) + + if symbolGraphFiles.isEmpty { + fail("No symbol graph JSON files found at \(configuration.symbolGraphsDirectory.path)") + } + + let allEntries = symbolGraphFiles.flatMap(loadSymbolEntries(from:)) + let entries = mergedEntries(allEntries) + let generated = generatedDocument(from: entries) + + let existingAPIDocument: String + do { + existingAPIDocument = try String(contentsOf: configuration.apiFilePath, encoding: .utf8) + } catch { + existingAPIDocument = "" + } + + let changed = generated != existingAPIDocument + + switch configuration.mode { + case .write: + if changed { + do { + try generated.write(to: configuration.apiFilePath, atomically: true, encoding: .utf8) + print("Updated APIs.md public API list.") + } catch { + fail("Unable to write updated API file at \(configuration.apiFilePath.path)") + } + } else { + print("APIs.md public API list is already up to date.") + } + case .check: + if changed { + print("APIs.md public API list is out of date.") + exit(2) + } else { + print("APIs.md public API list is up to date.") + } + } +} + +run() diff --git a/.github/workflows/public-api-sync.yml b/.github/workflows/public-api-sync.yml new file mode 100644 index 00000000..68339afb --- /dev/null +++ b/.github/workflows/public-api-sync.yml @@ -0,0 +1,54 @@ +name: Public API Sync + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + paths: + - "Sources/**" + - ".github/scripts/**" + - ".github/workflows/public-api-sync.yml" + - "APIs.md" + - "README.md" + +permissions: + contents: write + +jobs: + sync-public-api: + runs-on: macos-latest + + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Regenerate public API list + shell: bash + run: bash .github/scripts/generate-public-api-list.sh --write + + - name: Detect API doc changes + id: apis_diff + shell: bash + run: | + if git diff --quiet -- APIs.md; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Auto-commit APIs update + if: steps.apis_diff.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "Update generated public API list in APIs.md" + file_pattern: APIs.md + + - name: Fail when APIs.md cannot be auto-updated + if: steps.apis_diff.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name != github.repository + shell: bash + run: | + echo "::error::APIs.md public API list is out of date, but this PR branch is not writable by CI." + echo "::error::Run bash .github/scripts/generate-public-api-list.sh --write and push the result." + exit 1 diff --git a/APIs.md b/APIs.md new file mode 100644 index 00000000..115dab33 --- /dev/null +++ b/APIs.md @@ -0,0 +1,491 @@ +# Available APIs + +This file is auto-generated by `.github/scripts/generate-public-api-list.sh`. + +## Public API + +### View Namespace + +- `@MainActor var backport: Backport { get }` + +### Transition Namespace + +- `@MainActor static var backport: Backport { get }` + +### Environment Backports + +- `var backportDismiss: Backport.DismissAction { get }` +- `var backportDynamicTypeSize: Backport.DynamicTypeSize { get set }` +- `var backportHorizontalScrollIndicatorVisibility: Backport.ScrollIndicatorVisibility { get set }` +- `var backportIsPresented: Bool { get }` +- `var backportIsScrollEnabled: Bool { get set }` +- `var backportOpenURL: Backport.OpenURLAction { get set }` +- `var backportRefresh: Backport.RefreshAction? { get set }` +- `@MainActor var backportRequestReview: Backport.RequestReviewAction { get }` +- `var backportScrollDismissesKeyboardMode: Backport.ScrollDismissesKeyboardMode { get set }` +- `var backportVerticalScrollIndicatorVisibility: Backport.ScrollIndicatorVisibility { get set }` +- `var debugDescription: String { get }` + +### Modifiers + +- `static func < (lhs: Backport.PresentationDetent, rhs: Backport.PresentationDetent) -> Bool` +- `func callAsFunction()` +- `func callAsFunction() async` +- `func callAsFunction(_ url: URL)` +- `func callAsFunction(_ url: URL, completion: @escaping (Bool) -> Void)` +- `func labeledContentStyle(_ style: S) -> some View where S : BackportLabeledContentStyle` +- `func makeBody(configuration: Backport.AutomaticLabeledContentStyle.Configuration) -> some View` +- `func makeBody(configuration: Backport.CircularProgressViewStyle.Configuration) -> some View` +- `func makeBody(configuration: Backport.DefaultLabelStyle.Configuration) -> some View` +- `func makeBody(configuration: Backport.DefaultProgressViewStyle.Configuration) -> some View` +- `func makeBody(configuration: Backport.IconOnlyLabelStyle.Configuration) -> some View` +- `func makeBody(configuration: Backport.LinearProgressViewStyle.Configuration) -> some View` +- `func makeBody(configuration: Backport.TitleAndIconLabelStyle.Configuration) -> some View` +- `func makeBody(configuration: Backport.TitleOnlyLabelStyle.Configuration) -> some View` +- `func onChange(of value: V, initial: Bool = false, _ action: @escaping (V, V) -> Void) -> some View where V : Equatable` +- `func onChange(of value: Value, perform action: @escaping (Value) -> Void) -> some View where Value : Equatable` +- `func push(from edge: Edge) -> AnyTransition` +- `func quickLookPreview(_ item: Binding) -> some View` +- `func quickLookPreview(_ selection: Binding, in items: Items) -> some View where Items : RandomAccessCollection, Items.Element == URL` +- `@MainActor subscript(bounds: Range) -> Backport.SubviewsCollectionSlice { get }` +- `@MainActor subscript(bounds: Range) -> Backport.SubviewsCollectionSlice { get }` +- `@MainActor subscript(index: Int) -> Backport.Subview { get }` +- `@MainActor subscript(position: Int) -> Backport.Subview { get }` +- `@MainActor subscript(position: Int) -> Backport.Subview { get }` + +### Backport Namespace + +- `@ViewBuilder static func AsyncImage(url: URL?, scale: CGFloat = 1) -> some View` +- `@ViewBuilder static func AsyncImage(url: URL?, scale: CGFloat = 1, @ViewBuilder content: @escaping (Image) -> I, @ViewBuilder placeholder: @escaping () -> P) -> some View where I : View, P : View` +- `@ViewBuilder static func AsyncImage(url: URL?, scale: CGFloat = 1, transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (Backport.AsyncImagePhase) -> Content) -> some View where Content : View` +- `@MainActor @propertyWrapper struct AppStorage` +- `enum AsyncImagePhase` +- `case empty` +- `case failure(any Error)` +- `case success(Image)` +- `struct AutomaticLabeledContentStyle` +- `struct CircularProgressViewStyle` +- `@MainActor struct ContentUnavailableView where Label : View, Description : View, Actions : View` +- `struct DefaultLabelStyle` +- `struct DefaultProgressViewStyle` +- `struct DismissAction` +- `enum DynamicTypeSize` +- `case accessibility1` +- `case accessibility2` +- `case accessibility3` +- `case accessibility4` +- `case accessibility5` +- `case large` +- `case medium` +- `case small` +- `case xLarge` +- `case xSmall` +- `case xxLarge` +- `case xxxLarge` +- `@MainActor struct ForEach where Content : View` +- `@MainActor struct ForEachSubviewCollection where Content : View` +- `typealias Element = Backport.Subview` +- `typealias Index = Int` +- `typealias Indices = Range` +- `typealias Iterator = IndexingIterator.ForEachSubviewCollection>` +- `typealias SubSequence = Slice.ForEachSubviewCollection>` +- `@MainActor struct Group where Content : View` +- `@MainActor struct GroupElementsOfContent where Subviews : View, Content : View` +- `struct IconOnlyLabelStyle` +- `final class ImageRenderer where Content : View` +- `@MainActor struct Label where Title : View, Icon : View` +- `struct LabelStyleConfiguration` +- `@MainActor struct LabeledContent` +- `struct LabeledContentStyleConfiguration` +- `struct LinearProgressViewStyle` +- `@MainActor struct Link