├── .devcontainer └── devcontainer.json ├── .github └── assets │ └── soyokaze.png ├── .gitignore ├── .swiftformat ├── CommandLineTool └── main.swift ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Package.resolved ├── Package.swift ├── Plugins └── SwiftAtprotoPlugin │ └── plugin.swift ├── README.md ├── Sources ├── ATProtoMacro │ └── ATProtoMacro.swift ├── Macros │ └── Macros.swift ├── SourceControl │ ├── Git.swift │ ├── GitRepository.swift │ ├── LexiconConfig.swift │ ├── LexiconsStore.swift │ └── misc.swift ├── SwiftAtproto │ ├── AnyCodable.swift │ ├── AuthInfo.swift │ ├── DIDDocument.swift │ ├── SwiftAtproto.swift │ ├── URL+.swift │ ├── XRPCClientProtocol.swift │ └── extensions.swift └── SwiftAtprotoLex │ ├── SwiftAtprotoLex.swift │ ├── TypeSchema.swift │ └── misc.swift ├── SwiftAtproto.podspec ├── Tests ├── MacrosTests │ └── MacrosTests.swift └── SwiftAtprotoTests │ └── SwiftAtprotoTests.swift ├── docker-compose.yml └── format.sh /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Docker from Docker Compose", 3 | "dockerComposeFile": "../docker-compose.yml", 4 | "service": "app", 5 | "workspaceFolder": "/home/user/work", 6 | "customizations": { 7 | "vscode": { 8 | "settings": { 9 | "terminal.integrated.defaultProfile.linux": "bash", 10 | "[swift]": { 11 | "editor.formatOnSave": true 12 | }, 13 | "sourcekit-lsp.serverPath": "/usr/bin/sourcekit-lsp", 14 | "apple-swift-format.path": "/usr/bin/swift-format" 15 | }, 16 | "extensions": [ 17 | "vknabel.vscode-apple-swift-format", 18 | "sswg.swift-lang" 19 | ] 20 | } 21 | }, 22 | "remoteUser": "user" 23 | } 24 | -------------------------------------------------------------------------------- /.github/assets/soyokaze.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nnabeyang/swift-atproto/1bfe17cbd534da2fd72bc7fea4c15d53d6faa1cd/.github/assets/soyokaze.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /.vscode 4 | /.bundle 5 | /vendor 6 | /Packages 7 | xcuserdata/ 8 | DerivedData/ 9 | .swiftpm/configuration/registries.json 10 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 11 | .netrc 12 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --exclude .build 2 | --exclude .devcontainer 3 | --exclude .vscode 4 | --exclude .github 5 | 6 | # file options 7 | 8 | # format options 9 | 10 | --allman false 11 | --binarygrouping 4,8 12 | --commas always 13 | --comments indent 14 | --decimalgrouping 3,6 15 | --elseposition same-line 16 | --empty void 17 | --exponentcase lowercase 18 | --exponentgrouping disabled 19 | --fractiongrouping disabled 20 | --header ignore 21 | --hexgrouping 4,8 22 | --hexliteralcase uppercase 23 | --ifdef indent 24 | --indent 4 25 | --indentcase false 26 | --importgrouping testable-bottom 27 | --linebreaks lf 28 | --maxwidth none 29 | --octalgrouping 4,8 30 | --operatorfunc spaced 31 | --patternlet hoist 32 | --ranges spaced 33 | --self remove 34 | --semicolons inline 35 | --stripunusedargs always 36 | --swiftversion 6.0 37 | --trimwhitespace always 38 | --wraparguments preserve 39 | --wrapcollections preserve 40 | 41 | # rules 42 | 43 | --enable isEmpty -------------------------------------------------------------------------------- /CommandLineTool/main.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | import SourceControl 4 | import SwiftAtprotoLex 5 | 6 | struct Lexgen: ParsableCommand { 7 | private static let defaultModulePath = "Sources/Lexicon" 8 | static var configuration: CommandConfiguration { 9 | CommandConfiguration(commandName: "swift-atproto", version: SourceControl.version) 10 | } 11 | 12 | @Option(name: .customLong("atproto-configuration")) 13 | var configuration: String 14 | @Option(name: .long) 15 | var outdir: String? 16 | 17 | mutating func run() throws { 18 | let configurationtURL = URL(filePath: configuration) 19 | let data = try Data(contentsOf: configurationtURL) 20 | let config = try JSONDecoder().decode(LexiconConfig.self, from: data) 21 | let module = outdir ?? config.module ?? Self.defaultModulePath 22 | let rootURL = configurationtURL.deletingLastPathComponent() 23 | try SourceControl.main(rootURL: rootURL, config: config, module: module) 24 | try SwiftAtprotoLex.main(outdir: module, path: SourceControl.lexiconsDirectoryURL(packageRootURL: rootURL).path()) 25 | } 26 | } 27 | 28 | Lexgen.main() 29 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM swift:6.0-focal 2 | 3 | RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ 4 | && apt-get -q update \ 5 | && apt-get -q dist-upgrade -y \ 6 | && apt-get install -y libjemalloc-dev \ 7 | && rm -rf /var/lib/apt/lists/* 8 | 9 | ARG USERNAME=user 10 | ARG GROUPNAME=user 11 | ARG UID=1000 12 | ARG GID=1000 13 | ARG PASSWORD=user 14 | RUN groupadd -g $GID $GROUPNAME && \ 15 | useradd -m -s /bin/bash -u $UID -g $GID -G sudo $USERNAME && \ 16 | echo $USERNAME:$PASSWORD | chpasswd && \ 17 | echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers 18 | USER $USERNAME 19 | WORKDIR /home/$USERNAME/work 20 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gem 'cocoapods' 3 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.6) 5 | rexml 6 | activesupport (7.1.2) 7 | base64 8 | bigdecimal 9 | concurrent-ruby (~> 1.0, >= 1.0.2) 10 | connection_pool (>= 2.2.5) 11 | drb 12 | i18n (>= 1.6, < 2) 13 | minitest (>= 5.1) 14 | mutex_m 15 | tzinfo (~> 2.0) 16 | addressable (2.8.5) 17 | public_suffix (>= 2.0.2, < 6.0) 18 | algoliasearch (1.27.5) 19 | httpclient (~> 2.8, >= 2.8.3) 20 | json (>= 1.5.1) 21 | atomos (0.1.3) 22 | base64 (0.2.0) 23 | bigdecimal (3.1.4) 24 | claide (1.1.0) 25 | cocoapods (1.14.3) 26 | addressable (~> 2.8) 27 | claide (>= 1.0.2, < 2.0) 28 | cocoapods-core (= 1.14.3) 29 | cocoapods-deintegrate (>= 1.0.3, < 2.0) 30 | cocoapods-downloader (>= 2.1, < 3.0) 31 | cocoapods-plugins (>= 1.0.0, < 2.0) 32 | cocoapods-search (>= 1.0.0, < 2.0) 33 | cocoapods-trunk (>= 1.6.0, < 2.0) 34 | cocoapods-try (>= 1.1.0, < 2.0) 35 | colored2 (~> 3.1) 36 | escape (~> 0.0.4) 37 | fourflusher (>= 2.3.0, < 3.0) 38 | gh_inspector (~> 1.0) 39 | molinillo (~> 0.8.0) 40 | nap (~> 1.0) 41 | ruby-macho (>= 2.3.0, < 3.0) 42 | xcodeproj (>= 1.23.0, < 2.0) 43 | cocoapods-core (1.14.3) 44 | activesupport (>= 5.0, < 8) 45 | addressable (~> 2.8) 46 | algoliasearch (~> 1.0) 47 | concurrent-ruby (~> 1.1) 48 | fuzzy_match (~> 2.0.4) 49 | nap (~> 1.0) 50 | netrc (~> 0.11) 51 | public_suffix (~> 4.0) 52 | typhoeus (~> 1.0) 53 | cocoapods-deintegrate (1.0.5) 54 | cocoapods-downloader (2.1) 55 | cocoapods-plugins (1.0.0) 56 | nap 57 | cocoapods-search (1.0.1) 58 | cocoapods-trunk (1.6.0) 59 | nap (>= 0.8, < 2.0) 60 | netrc (~> 0.11) 61 | cocoapods-try (1.2.0) 62 | colored2 (3.1.2) 63 | concurrent-ruby (1.2.2) 64 | connection_pool (2.4.1) 65 | drb (2.2.0) 66 | ruby2_keywords 67 | escape (0.0.4) 68 | ethon (0.16.0) 69 | ffi (>= 1.15.0) 70 | ffi (1.16.3) 71 | fourflusher (2.3.1) 72 | fuzzy_match (2.0.4) 73 | gh_inspector (1.1.3) 74 | httpclient (2.8.3) 75 | i18n (1.14.1) 76 | concurrent-ruby (~> 1.0) 77 | json (2.7.1) 78 | minitest (5.20.0) 79 | molinillo (0.8.0) 80 | mutex_m (0.2.0) 81 | nanaimo (0.3.0) 82 | nap (1.1.0) 83 | netrc (0.11.0) 84 | public_suffix (4.0.7) 85 | rexml (3.2.6) 86 | ruby-macho (2.5.1) 87 | ruby2_keywords (0.0.5) 88 | typhoeus (1.4.1) 89 | ethon (>= 0.9.0) 90 | tzinfo (2.0.6) 91 | concurrent-ruby (~> 1.0) 92 | xcodeproj (1.23.0) 93 | CFPropertyList (>= 2.3.3, < 4.0) 94 | atomos (~> 0.1.3) 95 | claide (>= 1.0.2, < 2.0) 96 | colored2 (~> 3.1) 97 | nanaimo (~> 0.3.0) 98 | rexml (~> 3.2.4) 99 | 100 | PLATFORMS 101 | arm64-darwin-21 102 | 103 | DEPENDENCIES 104 | cocoapods 105 | 106 | BUNDLED WITH 107 | 2.3.21 108 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Noriaki Watanabe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "395ea43c764a0163fa0a80beef178baab4aa403b61f509d1cc871b1c96eeb631", 3 | "pins" : [ 4 | { 5 | "identity" : "async-http-client", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/swift-server/async-http-client.git", 8 | "state" : { 9 | "revision" : "333f51104b75d1a5b94cb3b99e4c58a3b442c9f7", 10 | "version" : "1.25.2" 11 | } 12 | }, 13 | { 14 | "identity" : "cryptoswift", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", 17 | "state" : { 18 | "revision" : "c9c3df6ab812de32bae61fc0cd1bf6d45170ebf0", 19 | "version" : "1.8.2" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-algorithms", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/apple/swift-algorithms.git", 26 | "state" : { 27 | "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", 28 | "version" : "1.2.1" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-argument-parser", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/apple/swift-argument-parser", 35 | "state" : { 36 | "revision" : "46989693916f56d1186bd59ac15124caef896560", 37 | "version" : "1.3.1" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-atomics", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/apple/swift-atomics.git", 44 | "state" : { 45 | "revision" : "cd142fd2f64be2100422d658e7411e39489da985", 46 | "version" : "1.2.0" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-bases", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/swift-libp2p/swift-bases.git", 53 | "state" : { 54 | "revision" : "3cf27cf95d70248b0a1d99eee06cdf8b235241a8", 55 | "version" : "0.0.3" 56 | } 57 | }, 58 | { 59 | "identity" : "swift-cid", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/swift-libp2p/swift-cid", 62 | "state" : { 63 | "revision" : "f2eb18409e87c0da057624b550a6c4a3e08de5c4", 64 | "version" : "0.0.1" 65 | } 66 | }, 67 | { 68 | "identity" : "swift-collections", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/apple/swift-collections.git", 71 | "state" : { 72 | "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", 73 | "version" : "1.1.4" 74 | } 75 | }, 76 | { 77 | "identity" : "swift-http-structured-headers", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/apple/swift-http-structured-headers.git", 80 | "state" : { 81 | "revision" : "d01361d32e14ae9b70ea5bd308a3794a198a2706", 82 | "version" : "1.2.0" 83 | } 84 | }, 85 | { 86 | "identity" : "swift-http-types", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/apple/swift-http-types.git", 89 | "state" : { 90 | "revision" : "ef18d829e8b92d731ad27bb81583edd2094d1ce3", 91 | "version" : "1.3.1" 92 | } 93 | }, 94 | { 95 | "identity" : "swift-log", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/apple/swift-log.git", 98 | "state" : { 99 | "revision" : "96a2f8a0fa41e9e09af4585e2724c4e825410b91", 100 | "version" : "1.6.2" 101 | } 102 | }, 103 | { 104 | "identity" : "swift-multibase", 105 | "kind" : "remoteSourceControl", 106 | "location" : "https://github.com/swift-libp2p/swift-multibase.git", 107 | "state" : { 108 | "revision" : "45f3cf2844477b9d211e1d3e793d0853134fd942", 109 | "version" : "0.0.2" 110 | } 111 | }, 112 | { 113 | "identity" : "swift-multicodec", 114 | "kind" : "remoteSourceControl", 115 | "location" : "https://github.com/swift-libp2p/swift-multicodec.git", 116 | "state" : { 117 | "revision" : "3ea551d87a15e390d48c379fda350e7495000929", 118 | "version" : "0.0.7" 119 | } 120 | }, 121 | { 122 | "identity" : "swift-multihash", 123 | "kind" : "remoteSourceControl", 124 | "location" : "https://github.com/swift-libp2p/swift-multihash.git", 125 | "state" : { 126 | "revision" : "7ea5a9866bd341601fa61647d521880944139353", 127 | "version" : "0.0.4" 128 | } 129 | }, 130 | { 131 | "identity" : "swift-nio", 132 | "kind" : "remoteSourceControl", 133 | "location" : "https://github.com/apple/swift-nio.git", 134 | "state" : { 135 | "revision" : "c51907a839e63ebf0ba2076bba73dd96436bd1b9", 136 | "version" : "2.81.0" 137 | } 138 | }, 139 | { 140 | "identity" : "swift-nio-extras", 141 | "kind" : "remoteSourceControl", 142 | "location" : "https://github.com/apple/swift-nio-extras.git", 143 | "state" : { 144 | "revision" : "00f3f72d2f9942d0e2dc96057ab50a37ced150d4", 145 | "version" : "1.25.0" 146 | } 147 | }, 148 | { 149 | "identity" : "swift-nio-http2", 150 | "kind" : "remoteSourceControl", 151 | "location" : "https://github.com/apple/swift-nio-http2.git", 152 | "state" : { 153 | "revision" : "170f4ca06b6a9c57b811293cebcb96e81b661310", 154 | "version" : "1.35.0" 155 | } 156 | }, 157 | { 158 | "identity" : "swift-nio-ssl", 159 | "kind" : "remoteSourceControl", 160 | "location" : "https://github.com/apple/swift-nio-ssl.git", 161 | "state" : { 162 | "revision" : "0cc3528ff48129d64ab9cab0b1cd621634edfc6b", 163 | "version" : "2.29.3" 164 | } 165 | }, 166 | { 167 | "identity" : "swift-nio-transport-services", 168 | "kind" : "remoteSourceControl", 169 | "location" : "https://github.com/apple/swift-nio-transport-services.git", 170 | "state" : { 171 | "revision" : "3c394067c08d1225ba8442e9cffb520ded417b64", 172 | "version" : "1.23.1" 173 | } 174 | }, 175 | { 176 | "identity" : "swift-numerics", 177 | "kind" : "remoteSourceControl", 178 | "location" : "https://github.com/apple/swift-numerics.git", 179 | "state" : { 180 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", 181 | "version" : "1.0.2" 182 | } 183 | }, 184 | { 185 | "identity" : "swift-syntax", 186 | "kind" : "remoteSourceControl", 187 | "location" : "https://github.com/swiftlang/swift-syntax.git", 188 | "state" : { 189 | "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", 190 | "version" : "601.0.1" 191 | } 192 | }, 193 | { 194 | "identity" : "swift-system", 195 | "kind" : "remoteSourceControl", 196 | "location" : "https://github.com/apple/swift-system.git", 197 | "state" : { 198 | "revision" : "a34201439c74b53f0fd71ef11741af7e7caf01e1", 199 | "version" : "1.4.2" 200 | } 201 | }, 202 | { 203 | "identity" : "swift-varint", 204 | "kind" : "remoteSourceControl", 205 | "location" : "https://github.com/swift-libp2p/swift-varint.git", 206 | "state" : { 207 | "revision" : "3d1e3c9ca4824d5acaf40ff46e96c0b956599271", 208 | "version" : "0.0.1" 209 | } 210 | }, 211 | { 212 | "identity" : "swiftformat", 213 | "kind" : "remoteSourceControl", 214 | "location" : "https://github.com/nicklockwood/SwiftFormat", 215 | "state" : { 216 | "revision" : "468a7d32dedc8d352c191594b3b45d9fd8ba291b", 217 | "version" : "0.55.5" 218 | } 219 | } 220 | ], 221 | "version" : 3 222 | } 223 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import CompilerPluginSupport 5 | import PackageDescription 6 | 7 | let package = Package( 8 | name: "SwiftAtproto", 9 | platforms: [.macOS(.v13), .iOS(.v16)], 10 | products: [ 11 | .library( 12 | name: "SwiftAtproto", 13 | targets: ["SwiftAtproto"] 14 | ), 15 | .library(name: "ATProtoMacro", 16 | targets: ["ATProtoMacro"]), 17 | .executable( 18 | name: "swift-atproto", 19 | targets: ["swift-atproto"] 20 | ), 21 | .plugin( 22 | name: "SwiftAtprotoPlugin", 23 | targets: ["Generate Source Code"] 24 | ), 25 | ], 26 | dependencies: [ 27 | .package(url: "https://github.com/swift-libp2p/swift-cid", exact: "0.0.1"), 28 | .package(url: "https://github.com/swiftlang/swift-syntax.git", exact: "601.0.1"), 29 | .package(url: "https://github.com/apple/swift-argument-parser", exact: "1.3.1"), 30 | .package(url: "https://github.com/nicklockwood/SwiftFormat", exact: "0.55.5"), 31 | .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.19.0"), 32 | .package(url: "https://github.com/apple/swift-nio.git", from: "2.63.0"), 33 | ], 34 | targets: [ 35 | .target( 36 | name: "SwiftAtproto", 37 | dependencies: [ 38 | .product(name: "CID", package: "swift-cid"), 39 | .product(name: "AsyncHTTPClient", package: "async-http-client", condition: .when(platforms: [.linux])), 40 | .product(name: "NIOHTTP1", package: "swift-nio", condition: .when(platforms: [.linux])), 41 | ] 42 | ), 43 | .target( 44 | name: "SwiftAtprotoLex", 45 | dependencies: [ 46 | .product(name: "SwiftSyntax", package: "swift-syntax"), 47 | .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), 48 | ] 49 | ), 50 | .target( 51 | name: "SourceControl" 52 | ), 53 | .executableTarget( 54 | name: "swift-atproto", 55 | dependencies: [ 56 | "SwiftAtprotoLex", 57 | "SourceControl", 58 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 59 | ], 60 | path: "CommandLineTool" 61 | ), 62 | .macro( 63 | name: "Macros", 64 | dependencies: [ 65 | .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), 66 | .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), 67 | ] 68 | ), 69 | .target(name: "ATProtoMacro", dependencies: ["Macros", "SwiftAtproto"]), 70 | .testTarget( 71 | name: "SwiftAtprotoTests", 72 | dependencies: ["SwiftAtproto"] 73 | ), 74 | .testTarget( 75 | name: "MacrosTests", 76 | dependencies: [ 77 | "Macros", 78 | .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), 79 | ] 80 | ), 81 | .plugin(name: "Generate Source Code", 82 | capability: .command( 83 | intent: .custom(verb: "swift-atproto", description: "Formats Swift source files using SwiftFormat"), 84 | permissions: [ 85 | .writeToPackageDirectory(reason: "This command reformats source files"), 86 | .allowNetworkConnections(scope: .all(ports: [443]), reason: "fetch lexicons"), 87 | ] 88 | ), 89 | dependencies: [.target(name: "swift-atproto")], 90 | path: "Plugins/SwiftAtprotoPlugin"), 91 | ] 92 | ) 93 | -------------------------------------------------------------------------------- /Plugins/SwiftAtprotoPlugin/plugin.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PackagePlugin 3 | 4 | @main 5 | struct SwiftAtprotoPlugin { 6 | func codeGenerate(tool: PluginContext.Tool, outputDirectoryPath: String?, configurationFilePath: String?) throws { 7 | let codeGenerationExec = tool.url 8 | var arguments = [String]() 9 | if let configurationFilePath { 10 | arguments.append(contentsOf: ["--atproto-configuration", configurationFilePath]) 11 | } 12 | if let outputDirectoryPath { 13 | arguments.append(contentsOf: ["--outdir", outputDirectoryPath]) 14 | } 15 | let process = try Process.run(codeGenerationExec, arguments: arguments) 16 | process.waitUntilExit() 17 | 18 | if process.terminationReason == .exit, process.terminationStatus == 0 { 19 | print("source code is generated.") 20 | } else { 21 | let problem = "\(process.terminationReason):\(process.terminationStatus)" 22 | Diagnostics.error("swift-atproto invocation failed: \(problem)") 23 | } 24 | } 25 | } 26 | 27 | extension SwiftAtprotoPlugin: CommandPlugin { 28 | func performCommand( 29 | context: PluginContext, 30 | arguments: [String] 31 | ) async throws { 32 | let codeGenerationTool = try context.tool(named: "swift-atproto") 33 | var argExtractor = ArgumentExtractor(arguments) 34 | let configurationFilePath: String? = if argExtractor.extractOption(named: "atproto-configuration").first == nil { 35 | context.package.directoryURL.appending(component: ".atproto.json").path() 36 | } else { 37 | nil 38 | } 39 | let outputDirectoryPath = argExtractor.extractOption(named: "outdir").first 40 | try codeGenerate(tool: codeGenerationTool, 41 | outputDirectoryPath: outputDirectoryPath, 42 | configurationFilePath: configurationFilePath) 43 | } 44 | } 45 | 46 | #if canImport(XcodeProjectPlugin) 47 | import XcodeProjectPlugin 48 | 49 | extension SwiftAtprotoPlugin: XcodeCommandPlugin { 50 | func performCommand(context: XcodeProjectPlugin.XcodePluginContext, arguments: [String]) throws { 51 | let codeGenerationTool = try context.tool(named: "swift-atproto") 52 | var argExtractor = ArgumentExtractor(arguments) 53 | let configurationFilePath: String? = if argExtractor.extractOption(named: "atproto-configuration").first == nil { 54 | context.xcodeProject.directoryURL.appending(component: ".atproto.json").path() 55 | } else { 56 | nil 57 | } 58 | 59 | let outputDirectoryPath = argExtractor.extractOption(named: "outdir").first 60 | try codeGenerate(tool: codeGenerationTool, 61 | outputDirectoryPath: outputDirectoryPath, 62 | configurationFilePath: configurationFilePath) 63 | } 64 | } 65 | #endif 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # swift-atproto 2 | 3 | swift-atproto is a atproto library. 4 | 5 | ## Installation 6 | 7 | ### SwiftPM 8 | 9 | Add the `SwiftAtproto` as a dependency: 10 | 11 | ```swift 12 | let package = Package( 13 | // name, platforms, products, etc. 14 | dependencies: [ 15 | // other dependencies 16 | .package(url: "https://github.com/nnabeyang/swift-atproto", from: "0.28.2"), 17 | ], 18 | targets: [ 19 | .executableTarget(name: "", dependencies: [ 20 | // other dependencies 21 | .product(name: "SwiftAtproto", package: "swift-atproto"), 22 | ]), 23 | // other targets 24 | ] 25 | ) 26 | ``` 27 | 28 | ### CocoaPods 29 | 30 | Add the following to your Podfile: 31 | 32 | ```terminal 33 | pod 'SwiftAtproto' 34 | ``` 35 | 36 | ### Usage 37 | 38 | Code generation is done as follows: 39 | ```bash 40 | swift package plugin --allow-writing-to-package-directory \ 41 | --allow-network-connections all:443 swift-atproto --outdir --atproto-configuration ./.atproto.json 42 | ``` 43 | 44 | Sample configuration file is as follows: 45 | ```json 46 | { 47 | "dependencies": [ 48 | { 49 | "location": "https://github.com/bluesky-social/atproto.git", 50 | "lexicons": [ 51 | { "prefix": "app.bsky", "path": "lexicons/app/bsky" }, 52 | { "prefix": "com.atproto", "path": "lexicons/com/atproto" }, 53 | { "prefix": "tools/ozone", "path": "lexicons/tools/ozone" } 54 | ], 55 | "state": { 56 | "tag": "@atproto/api@0.15.5" 57 | } 58 | }, 59 | { 60 | "location": "https://github.com/nnabeyang/soyokaze-lexicons.git", 61 | "lexicons": [ 62 | { 63 | "prefix": "com.nnabeyang.soyokaze", 64 | "path": "lexicons/com/nnabeyang/soyokaze" 65 | } 66 | ], 67 | "state": { 68 | "tag": "0.0.1" 69 | } 70 | } 71 | ], 72 | "module": "Sources/Lexicon" 73 | } 74 | ``` 75 | 76 | ## Apps Using 77 | 78 |

79 | 80 |

81 | 82 | ## License 83 | 84 | swift-atproto is published under the MIT License, see LICENSE. 85 | 86 | ## Author 87 | [Noriaki Watanabe@nnabeyang](https://bsky.app/profile/did:plc:bnh3bvyqr3vzxyvjdnrrusbr) 88 | -------------------------------------------------------------------------------- /Sources/ATProtoMacro/ATProtoMacro.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ATProtoMacro.swift 3 | // SwiftAtproto 4 | // 5 | // Created by Noriaki Watanabe on 2024/12/23. 6 | // 7 | 8 | import SwiftAtproto 9 | 10 | @attached(member, names: named(init)) 11 | @attached(extension, conformances: XRPCClientProtocol) 12 | public macro XRPCClient() = #externalMacro(module: "Macros", type: "XRPCClientMacro") 13 | -------------------------------------------------------------------------------- /Sources/Macros/Macros.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Macros.swift 3 | // SwiftAtproto 4 | // 5 | // Created by Noriaki Watanabe on 2024/12/23. 6 | // 7 | 8 | import SwiftCompilerPlugin 9 | import SwiftDiagnostics 10 | import SwiftSyntax 11 | import SwiftSyntaxBuilder 12 | import SwiftSyntaxMacros 13 | 14 | public struct XRPCClientMacro {} 15 | 16 | extension XRPCClientMacro: MemberMacro { 17 | public static func expansion(of _: AttributeSyntax, 18 | providingMembersOf declaration: some DeclGroupSyntax, 19 | conformingTo _: [TypeSyntax], 20 | in _: some MacroExpansionContext) throws -> [DeclSyntax] 21 | { 22 | var parameters = [FunctionParameterSyntax]() 23 | var variables = [VariableDeclSyntax]() 24 | var codeblocks = [CodeBlockItemSyntax]() 25 | for member in declaration.memberBlock.members { 26 | guard let v = member.decl.as(VariableDeclSyntax.self), 27 | let binding = v.bindings.first, 28 | binding.accessorBlock == nil, 29 | let name = binding.pattern.as(IdentifierPatternSyntax.self) 30 | else { 31 | continue 32 | } 33 | guard name.identifier.trimmedDescription != "decoder" else { continue } 34 | variables.append(v) 35 | } 36 | let last = variables.count - 1 37 | for (i, decl) in variables.enumerated() { 38 | guard 39 | let binding = decl.bindings.first, 40 | let type = binding.typeAnnotation?.type, 41 | let name = binding.pattern.as(IdentifierPatternSyntax.self) 42 | else { continue } 43 | parameters.append( 44 | FunctionParameterSyntax( 45 | firstName: name.identifier, 46 | colon: .colonToken(), 47 | type: type, 48 | trailingComma: i == last ? nil : .commaToken() 49 | )) 50 | codeblocks.append(CodeBlockItemSyntax(item: CodeBlockItemSyntax.Item(SequenceExprSyntax(elements: ExprListSyntax([ 51 | ExprSyntax(MemberAccessExprSyntax( 52 | base: ExprSyntax(DeclReferenceExprSyntax(baseName: .keyword(.self))), 53 | period: .periodToken(), 54 | declName: DeclReferenceExprSyntax(baseName: name.identifier) 55 | )), 56 | ExprSyntax(AssignmentExprSyntax(equal: .equalToken())), 57 | ExprSyntax(DeclReferenceExprSyntax(baseName: name.identifier)), 58 | ]))))) 59 | } 60 | codeblocks.append(CodeBlockItemSyntax(item: CodeBlockItemSyntax.Item(SequenceExprSyntax(elements: ExprListSyntax([ 61 | ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier("decoder"))), 62 | ExprSyntax(AssignmentExprSyntax(equal: .equalToken())), 63 | ExprSyntax(FunctionCallExprSyntax( 64 | calledExpression: ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier("JSONDecoder"))), 65 | leftParen: .leftParenToken(), 66 | arguments: LabeledExprListSyntax([]), 67 | rightParen: .rightParenToken() 68 | )), 69 | ]))))) 70 | codeblocks.append( 71 | CodeBlockItemSyntax(item: CodeBlockItemSyntax.Item(FunctionCallExprSyntax( 72 | calledExpression: ExprSyntax(MemberAccessExprSyntax( 73 | base: ExprSyntax(DeclReferenceExprSyntax(baseName: .keyword(.Self))), 74 | period: .periodToken(), 75 | declName: DeclReferenceExprSyntax(baseName: .identifier("setModuleName")) 76 | )), 77 | leftParen: .leftParenToken(), 78 | arguments: LabeledExprListSyntax([]), 79 | rightParen: .rightParenToken() 80 | )))) 81 | return [DeclSyntax(InitializerDeclSyntax( 82 | attributes: AttributeListSyntax([]), 83 | modifiers: DeclModifierListSyntax([ 84 | DeclModifierSyntax(name: .keyword(.private)), 85 | ]), 86 | initKeyword: .keyword(.`init`), 87 | signature: FunctionSignatureSyntax(parameterClause: FunctionParameterClauseSyntax( 88 | leftParen: .leftParenToken(), 89 | parameters: FunctionParameterListSyntax(parameters), 90 | rightParen: .rightParenToken() 91 | )), 92 | body: CodeBlockSyntax( 93 | leftBrace: .leftBraceToken(), 94 | statements: CodeBlockItemListSyntax(codeblocks), 95 | rightBrace: .rightBraceToken() 96 | ) 97 | ))] 98 | } 99 | } 100 | 101 | extension XRPCClientMacro: ExtensionMacro { 102 | public static func expansion( 103 | of _: AttributeSyntax, 104 | attachedTo _: some DeclGroupSyntax, 105 | providingExtensionsOf type: some TypeSyntaxProtocol, 106 | conformingTo _: [TypeSyntax], 107 | in _: some MacroExpansionContext 108 | ) throws -> [ExtensionDeclSyntax] { 109 | [ExtensionDeclSyntax( 110 | extensionKeyword: .keyword(.extension), 111 | extendedType: TypeSyntax(IdentifierTypeSyntax(name: .identifier(type.trimmedDescription))), 112 | inheritanceClause: InheritanceClauseSyntax( 113 | colon: .colonToken(), 114 | inheritedTypes: InheritedTypeListSyntax([ 115 | InheritedTypeSyntax(type: TypeSyntax(IdentifierTypeSyntax(name: .identifier("XRPCClientProtocol")))), 116 | ]) 117 | ), 118 | memberBlock: MemberBlockSyntax( 119 | leftBrace: .leftBraceToken(), 120 | members: MemberBlockItemListSyntax([]), 121 | rightBrace: .rightBraceToken() 122 | ) 123 | )] 124 | } 125 | } 126 | 127 | @main 128 | struct ATProtoMacroPlugin: CompilerPlugin { 129 | let providingMacros: [Macro.Type] = [ 130 | XRPCClientMacro.self, 131 | ] 132 | } 133 | -------------------------------------------------------------------------------- /Sources/SourceControl/Git.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum Git { 4 | static var tool: String { "git" } 5 | 6 | private nonisolated(unsafe) static var _gitEnvironment: [String: String]? 7 | private static let lock = NSLock() 8 | 9 | private static let underrideEnvironment = [ 10 | "GIT_TERMINAL_PROMPT": "0", 11 | "GIT_SSH_COMMAND": "ssh -oBatchMode=yes", 12 | ] 13 | 14 | public static var environment: [String: String] { 15 | get { 16 | lock.lock() 17 | defer { 18 | lock.unlock() 19 | } 20 | var env = _gitEnvironment ?? ProcessInfo.processInfo.environment 21 | for (key, value) in underrideEnvironment { 22 | if env.keys.contains(key) { continue } 23 | env[key] = value 24 | } 25 | return env 26 | } 27 | set { 28 | lock.lock() 29 | defer { 30 | lock.unlock() 31 | } 32 | _gitEnvironment = newValue 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/SourceControl/GitRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum ProcessError: Error { 4 | case missingExecutableProgram(program: String) 5 | } 6 | 7 | public struct GitShellError: Error { 8 | let exitStatus: Int32 9 | } 10 | 11 | public enum GitShellHelper { 12 | @discardableResult 13 | public static func run(_ arguments: [String], environment: [String: String] = Git.environment) throws -> String { 14 | let program = Git.tool 15 | guard let executableURL = findExecutable(program) else { 16 | throw ProcessError.missingExecutableProgram(program: program) 17 | } 18 | let process = Process() 19 | process.environment = environment 20 | process.executableURL = executableURL 21 | process.arguments = arguments 22 | let stdout = Pipe() 23 | process.standardOutput = stdout 24 | process.standardError = Pipe() 25 | try process.run() 26 | process.waitUntilExit() 27 | if process.terminationReason == .exit, process.terminationStatus == 0 { 28 | let data = stdout.fileHandleForReading.availableData 29 | return String(decoding: data, as: UTF8.self) 30 | } else { 31 | throw GitShellError(exitStatus: process.terminationStatus) 32 | } 33 | } 34 | 35 | static func findExecutable(_ program: String) -> URL? { 36 | let currentWorkingDirectory = URL(filePath: FileManager.default.currentDirectoryPath) 37 | let searchPaths = Git.environment["PATH"].flatMap { getEnvSearchPaths(pathString: $0) } ?? [] 38 | return lookupExecutablePath(filename: program, currentWorkingDirectory: currentWorkingDirectory, searchPaths: searchPaths) 39 | } 40 | } 41 | 42 | public enum GitRepositoryProvider { 43 | public static func clone( 44 | origin: String, 45 | destination: String, 46 | options: [String] 47 | ) throws { 48 | let invocation: [String] = [ 49 | "clone", 50 | "-c", "core.fsmonitor=false", 51 | ] + options + [origin, destination] 52 | try GitShellHelper.run(invocation) 53 | } 54 | 55 | public static func createWorkingCopy(sourcePath: String, at destinationPath: String) throws -> GitRepository { 56 | try clone(origin: sourcePath, destination: destinationPath, options: ["--no-checkout"]) 57 | return openWorkingCopy(at: destinationPath) 58 | } 59 | 60 | public static func workingCopyExists(at path: String) -> Bool { 61 | guard FileManager.default.fileExists(atPath: path) else { return false } 62 | let repo = GitRepository(path: path) 63 | return (try? repo.checkoutExists()) ?? false 64 | } 65 | 66 | public static func openWorkingCopy(at path: String) -> GitRepository { 67 | GitRepository(path: path) 68 | } 69 | } 70 | 71 | public final class GitRepository { 72 | public let path: String 73 | private let lock = NSLock() 74 | private let isWorkingRepo: Bool 75 | init(path: String, isWorkingRepo: Bool = true) { 76 | self.path = path 77 | self.isWorkingRepo = isWorkingRepo 78 | } 79 | 80 | @discardableResult 81 | public func callGit(_ arguments: [String], environment: [String: String] = Git.environment) throws -> String { 82 | try GitShellHelper.run(["-C", path] + arguments, environment: environment) 83 | } 84 | 85 | public func checkout(tag: String) throws { 86 | _ = try lock.withLock { 87 | try callGit([ 88 | "reset", 89 | "--hard", 90 | tag, 91 | ]) 92 | } 93 | } 94 | 95 | func isBare() throws -> Bool { 96 | try lock.withLock { 97 | let output = try callGit([ 98 | "rev-parse", 99 | "--is-bare-repository", 100 | ]) 101 | return output == "true" 102 | } 103 | } 104 | 105 | func checkoutExists() throws -> Bool { 106 | try !isBare() 107 | } 108 | 109 | public func resolveRevision(tag: String) throws -> String { 110 | let specifier = "\(tag)^{commit}" 111 | return try lock.withLock { 112 | let output = try callGit([ 113 | "rev-parse", 114 | "--verify", 115 | specifier, 116 | ]) 117 | return output.trimmingCharacters(in: .newlines) 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Sources/SourceControl/LexiconConfig.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct LexiconConfig: Codable, Sendable { 4 | public let dependencies: [LexiconDependency] 5 | public let module: String? 6 | } 7 | 8 | public struct LexiconDependency: Codable, Sendable { 9 | public struct Lexicon: Codable, Sendable { 10 | public let prefix: String 11 | public let path: String 12 | } 13 | 14 | public struct SourceState: Codable, Sendable { 15 | public let tag: String 16 | } 17 | 18 | public let location: URL 19 | public let lexicons: [Lexicon] 20 | public let state: SourceState 21 | } 22 | -------------------------------------------------------------------------------- /Sources/SourceControl/LexiconsStore.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct LexiconsStore: Codable { 4 | public let generator: String 5 | public let module: String 6 | public let dependencies: [ResolvedLexiconDependency] 7 | 8 | public func write(to url: URL) throws { 9 | let encoder = JSONEncoder() 10 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] 11 | let data = try encoder.encode(self) 12 | try data.write(to: url) 13 | } 14 | } 15 | 16 | public struct ResolvedLexiconDependency: Codable { 17 | public struct Lexicon: Codable { 18 | public let prefix: String 19 | public let path: String 20 | } 21 | 22 | public struct SourceState: Codable { 23 | public let tag: String 24 | public let revison: String 25 | } 26 | 27 | public let location: URL 28 | public let lexicons: [LexiconDependency.Lexicon] 29 | public let state: SourceState 30 | 31 | public init(config: LexiconDependency, revision: String) { 32 | location = config.location 33 | lexicons = config.lexicons 34 | state = SourceState(tag: config.state.tag, revison: revision) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/SourceControl/misc.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public var version: String { "0.28.2" } 4 | 5 | public func getEnvSearchPaths(pathString: String) -> [URL] { 6 | pathString.split(separator: ":").map { URL(filePath: String($0)) } 7 | } 8 | 9 | public func lookupExecutablePath(filename value: String?, currentWorkingDirectory: URL, searchPaths: [URL]) -> URL? { 10 | guard let value, !value.isEmpty else { return nil } 11 | var urls = [URL]() 12 | if value.hasPrefix("/") { 13 | urls.append(URL(filePath: value)) 14 | } else if !value.contains("/") { 15 | urls.append(contentsOf: searchPaths.map { $0.appending(path: value) }) 16 | } else { 17 | urls.append(currentWorkingDirectory.appending(path: value)) 18 | } 19 | 20 | return urls.first(where: { FileManager.default.isExecutableFile(atPath: $0.path) }) 21 | } 22 | 23 | public func checkoutDirectoryURL(packageRootURL: URL) -> URL { 24 | packageRootURL.appending(components: ".lexicons", "checkouts") 25 | } 26 | 27 | public func lexiconsDirectoryURL(packageRootURL: URL) -> URL { 28 | packageRootURL.appending(components: ".lexicons", "lexicons") 29 | } 30 | 31 | public func main(rootURL: URL, config: LexiconConfig, module: String) throws { 32 | let checkoutDirectory = checkoutDirectoryURL(packageRootURL: rootURL) 33 | let lexiconsDirectory = lexiconsDirectoryURL(packageRootURL: rootURL) 34 | 35 | if !FileManager.default.fileExists(atPath: lexiconsDirectory.path()) { 36 | try FileManager.default.createDirectory(at: lexiconsDirectory, withIntermediateDirectories: true) 37 | } 38 | var resolvedDendencies = [ResolvedLexiconDependency]() 39 | for dependency in config.dependencies { 40 | var name = dependency.location.lastPathComponent 41 | if name.hasSuffix(".git") { 42 | name = String(name.dropLast(4)) 43 | } 44 | let destURL = checkoutDirectory.appending(component: name) 45 | if !GitRepositoryProvider.workingCopyExists(at: destURL.path()) { 46 | let clone = try GitRepositoryProvider.createWorkingCopy(sourcePath: dependency.location.absoluteString, 47 | at: destURL.path()) 48 | let tag = dependency.state.tag 49 | try clone.checkout(tag: tag) 50 | let revision = try clone.resolveRevision(tag: tag) 51 | resolvedDendencies.append(.init(config: dependency, revision: revision)) 52 | } 53 | for lexicon in dependency.lexicons { 54 | let srcBaseURL = destURL.appending(component: lexicon.path) 55 | for name in try FileManager.default.contentsOfDirectory(atPath: srcBaseURL.path()) { 56 | let srcURL = srcBaseURL.appending(component: name) 57 | let lexiconBaseDirectory = lexiconsDirectory.appending(component: lexicon.prefix.replacingOccurrences(of: ".", with: "/")) 58 | if !FileManager.default.fileExists(atPath: lexiconBaseDirectory.path()) { 59 | try FileManager.default.createDirectory(at: lexiconBaseDirectory, withIntermediateDirectories: true) 60 | } 61 | let lexiconDirectory = lexiconBaseDirectory.appending(component: name) 62 | if !FileManager.default.fileExists(atPath: lexiconDirectory.path()) { 63 | try FileManager.default.copyItem(at: srcURL, to: lexiconDirectory) 64 | } 65 | } 66 | } 67 | } 68 | if resolvedDendencies.count == config.dependencies.count { 69 | let store = LexiconsStore(generator: version, module: module, dependencies: resolvedDendencies) 70 | try store.write(to: rootURL.appending(component: ".atproto-lock.json")) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/SwiftAtproto/AnyCodable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct AnyCodable: @unchecked Sendable { 4 | enum BoxType { 5 | case encodable(_AnyBaseBox) 6 | case equatable(_AnyEquatableBox) 7 | case hashable(_AnyHashableBox) 8 | 9 | var encodable: _AnyBaseBox { 10 | switch self { 11 | case let .encodable(box): box 12 | case let .equatable(box): box 13 | case let .hashable(box): box 14 | } 15 | } 16 | 17 | var equatable: _AnyEquatableBox? { 18 | switch self { 19 | case .encodable: nil 20 | case let .equatable(box): box 21 | case let .hashable(box): box 22 | } 23 | } 24 | 25 | var base: Any { 26 | switch self { 27 | case let .encodable(box): box._base 28 | case let .equatable(box): box._base 29 | case let .hashable(box): box._base 30 | } 31 | } 32 | } 33 | 34 | var _box: BoxType 35 | 36 | init(encodable box: _AnyBaseBox) { 37 | _box = .encodable(box) 38 | } 39 | 40 | init(equatable box: _AnyEquatableBox) { 41 | _box = .equatable(box) 42 | } 43 | 44 | init(hashable box: _AnyHashableBox) { 45 | _box = .hashable(box) 46 | } 47 | 48 | init(_ base: some Encodable) { 49 | self.init(encodable: _ConcreateCodableBox(base)) 50 | } 51 | 52 | init(_ base: some Encodable & Equatable) { 53 | self.init(equatable: _ConcreateCodableBox(base)) 54 | } 55 | 56 | init(_ base: some Encodable & Hashable) { 57 | self.init(hashable: _ConcreateCodableBox(base)) 58 | } 59 | 60 | var base: Any { 61 | _box.base 62 | } 63 | } 64 | 65 | extension AnyCodable: Equatable { 66 | public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool { 67 | guard let lhs = lhs._box.equatable else { 68 | return false 69 | } 70 | guard let rhs = rhs._box.equatable else { 71 | return false 72 | } 73 | return lhs._isEqual(to: rhs) ?? false 74 | } 75 | } 76 | 77 | extension AnyCodable: Hashable { 78 | public func hash(into hasher: inout Hasher) { 79 | if case let .hashable(box) = _box { 80 | box._hash(into: &hasher) 81 | return 82 | } 83 | _abstract() 84 | } 85 | } 86 | 87 | extension AnyCodable: Encodable { 88 | public func encode(to encoder: Encoder) throws { 89 | try _box.encodable._encode(to: encoder) 90 | } 91 | } 92 | 93 | extension AnyCodable: Decodable { 94 | public init(from decoder: Decoder) throws { 95 | let container = try decoder.singleValueContainer() 96 | if container.decodeNil() { 97 | self.init(Self?.none) 98 | return 99 | } 100 | 101 | if let bool = try? container.decode(Bool.self) { 102 | self.init(bool) 103 | return 104 | } 105 | if let int = try? container.decode(Int.self) { 106 | self.init(int) 107 | return 108 | } 109 | if let uint = try? container.decode(UInt.self) { 110 | self.init(uint) 111 | return 112 | } 113 | if let string = try? container.decode(String.self) { 114 | self.init(string) 115 | return 116 | } 117 | if let float = try? container.decode(Double.self) { 118 | self.init(float) 119 | return 120 | } 121 | if let data = try? container.decode(Data.self) { 122 | self.init(data) 123 | return 124 | } 125 | if let link = try? container.decode(LexLink.self) { 126 | self.init(link) 127 | return 128 | } 129 | if let unknown = try? container.decode(LexiconTypeDecoder.self) { 130 | self.init(unknown) 131 | return 132 | } 133 | if let dictionary = try? container.decode([String: AnyCodable].self) { 134 | self.init(dictionary) 135 | return 136 | } 137 | if let array = try? container.decode([AnyCodable].self) { 138 | self.init(array) 139 | return 140 | } 141 | 142 | throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyCodable value cannot be decoded") 143 | } 144 | } 145 | 146 | extension AnyCodable: CustomDebugStringConvertible { 147 | public var debugDescription: String { 148 | guard let base = base as? CustomDebugStringConvertible else { 149 | return "AnyCodable(\(base))" 150 | } 151 | return "AnyCodable(\(base.debugDescription))" 152 | } 153 | } 154 | 155 | struct _ConcreateCodableBox { 156 | let _baseCodableKey: Base 157 | init(_ base: Base) { 158 | _baseCodableKey = base 159 | } 160 | 161 | var _base: Any { 162 | _baseCodableKey 163 | } 164 | } 165 | 166 | protocol _AnyBaseBox { 167 | func _encode(to encoder: Encoder) throws 168 | var _base: Any { get } 169 | } 170 | 171 | extension _ConcreateCodableBox: _AnyBaseBox where Base: Encodable { 172 | func _encode(to encoder: Encoder) throws { 173 | var container = encoder.singleValueContainer() 174 | try container.encode(_baseCodableKey) 175 | } 176 | } 177 | 178 | protocol _AnyEquatableBox: _AnyBaseBox { 179 | func _isEqual(to box: _AnyEquatableBox) -> Bool? 180 | func _unbox() -> T? 181 | } 182 | 183 | extension _ConcreateCodableBox: _AnyEquatableBox where Base: Encodable & Equatable { 184 | func _unbox() -> T? { 185 | (self as _AnyEquatableBox as? _ConcreateCodableBox)?._baseCodableKey 186 | } 187 | 188 | func _isEqual(to rhs: _AnyEquatableBox) -> Bool? { 189 | if let rhs: Base = rhs._unbox() { 190 | return _baseCodableKey == rhs 191 | } 192 | return nil 193 | } 194 | } 195 | 196 | protocol _AnyHashableBox: _AnyEquatableBox { 197 | func _hash(into hasher: inout Hasher) 198 | } 199 | 200 | extension _ConcreateCodableBox: _AnyHashableBox where Base: Encodable & Hashable { 201 | func _hash(into hasher: inout Hasher) { 202 | _baseCodableKey.hash(into: &hasher) 203 | } 204 | } 205 | 206 | public struct AnyCodingKeys: CodingKey { 207 | public var stringValue: String 208 | public var intValue: Int? 209 | 210 | public init(stringValue: String) { self.stringValue = stringValue } 211 | 212 | public init(intValue: Int) { 213 | stringValue = String(intValue) 214 | self.intValue = intValue 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /Sources/SwiftAtproto/AuthInfo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol XRPCAuth: Sendable, Equatable, Hashable { 4 | var accessJwt: String { get set } 5 | var refreshJwt: String { get set } 6 | var handle: String { get set } 7 | var did: String { get set } 8 | var serviceEndPoint: URL? { get set } 9 | } 10 | 11 | public extension XRPCAuth { 12 | static func == (lhs: Self, rhs: Self) -> Bool { 13 | lhs.accessJwt == rhs.accessJwt && lhs.refreshJwt == rhs.refreshJwt 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SwiftAtproto/DIDDocument.swift: -------------------------------------------------------------------------------- 1 | public struct DIDDocument: Codable, Sendable { 2 | public let context: [String] 3 | public let did: String 4 | public let alsoKnownAs: [String]? 5 | public let verificationMethod: [DocVerificationMethod]? 6 | public let service: [DocService]? 7 | 8 | enum CodingKeys: String, CodingKey { 9 | case context = "@context" 10 | case did = "id" 11 | case alsoKnownAs 12 | case verificationMethod 13 | case service 14 | } 15 | } 16 | 17 | public struct DocVerificationMethod: Codable, Sendable { 18 | let id: String 19 | let type: String 20 | let controller: String 21 | let publicKeyMultibase: String 22 | } 23 | 24 | public struct DocService: Codable, Sendable { 25 | public let id: String 26 | public let type: String 27 | public let serviceEndpoint: String 28 | } 29 | -------------------------------------------------------------------------------- /Sources/SwiftAtproto/SwiftAtproto.swift: -------------------------------------------------------------------------------- 1 | import CID 2 | import Foundation 3 | 4 | final class LexiconTypesMap: @unchecked Sendable { 5 | static let shared = LexiconTypesMap() 6 | private var map = [String: any ATProtoRecord.Type]() 7 | private var _moduleName: String = "" 8 | private let lock = NSLock() 9 | 10 | var moduleName: String { 11 | get { 12 | lock.lock() 13 | defer { 14 | lock.unlock() 15 | } 16 | return _moduleName 17 | } 18 | set { 19 | lock.lock() 20 | defer { 21 | lock.unlock() 22 | } 23 | _moduleName = newValue 24 | } 25 | } 26 | 27 | subscript(_ id: String) -> (any ATProtoRecord.Type)? { 28 | get { 29 | lock.lock() 30 | defer { 31 | lock.unlock() 32 | } 33 | return map[id] 34 | } 35 | set { 36 | lock.lock() 37 | defer { 38 | lock.unlock() 39 | } 40 | map[id] = newValue 41 | } 42 | } 43 | } 44 | 45 | public struct EmptyResponse: Codable {} 46 | 47 | public typealias ATProtoRecord = Codable & Sendable 48 | 49 | public struct LexiconTypeDecoder: Codable, Sendable { 50 | let typeName: String? 51 | public let val: any Codable & Sendable 52 | private enum CodingKeys: String, CodingKey { 53 | case type = "$type" 54 | } 55 | 56 | public init(typeName: String, val: any ATProtoRecord) { 57 | self.typeName = typeName 58 | self.val = val 59 | } 60 | 61 | public init(from decoder: Decoder) throws { 62 | let container = try decoder.container(keyedBy: CodingKeys.self) 63 | if let typeName = try container.decodeIfPresent(String.self, forKey: .type) { 64 | guard let type = Self.getTypeByName(typeName: typeName) else { 65 | val = try UnknownRecord(from: decoder) 66 | self.typeName = typeName 67 | return 68 | } 69 | val = try type.init(from: decoder) 70 | self.typeName = typeName 71 | } else { 72 | val = try DIDDocument(from: decoder) 73 | typeName = nil 74 | } 75 | } 76 | 77 | public func encode(to encoder: Encoder) throws { 78 | var container = encoder.container(keyedBy: CodingKeys.self) 79 | try container.encodeIfPresent(typeName, forKey: .type) 80 | try val.encode(to: encoder) 81 | } 82 | 83 | private static func getTypeByName(typeName nsId: String) -> (any ATProtoRecord.Type)? { 84 | if let type = LexiconTypesMap.shared[nsId] { 85 | return type 86 | } 87 | var components = nsId.split(separator: ".") 88 | while !components.isEmpty { 89 | components.removeLast() 90 | let prefix = components.joined(separator: ".") 91 | let typeName = "\(LexiconTypesMap.shared.moduleName).\(Self.structNameFor(prefix: prefix))_\(Self.nameFromId(id: nsId, prefix: prefix))" 92 | if let type = _typeByName(typeName) as? (any ATProtoRecord.Type) { 93 | LexiconTypesMap.shared[nsId] = type 94 | return type 95 | } 96 | } 97 | return nil 98 | } 99 | 100 | private static func nameFromId(id: String, prefix: String) -> String { 101 | id.trim(prefix: prefix).split(separator: ".").map { 102 | String($0).titleCased 103 | }.joined() 104 | } 105 | 106 | private static func structNameFor(prefix: String) -> String { 107 | "\(prefix.split(separator: ".").joined())types" 108 | } 109 | } 110 | 111 | extension String { 112 | func trim(prefix: String) -> String { 113 | guard hasPrefix(prefix) else { return self } 114 | return String(dropFirst(prefix.count)) 115 | } 116 | 117 | var titleCased: String { 118 | var prev = Character(" ") 119 | return String(map { 120 | if prev.isWhitespace { 121 | prev = $0 122 | return Character($0.uppercased()) 123 | } 124 | prev = $0 125 | return $0 126 | }) 127 | } 128 | 129 | func camelCased() -> String { 130 | guard !isEmpty else { return "" } 131 | let words = components(separatedBy: CharacterSet.alphanumerics.inverted).filter { !$0.isEmpty } 132 | let first = words.first!.lowercased() 133 | let rest = words.dropFirst().map(\.capitalized) 134 | return ([first] + rest).joined() 135 | } 136 | } 137 | 138 | public typealias LexLink = CID 139 | extension CID: @unchecked @retroactive Sendable {} 140 | 141 | extension LexLink: Codable { 142 | static func dataEncodingStrategy(data: Data, encoder: any Encoder) throws { 143 | let cid = try CID(data[1...]) 144 | var container = encoder.container(keyedBy: LexLink.CodingKeys.self) 145 | try container.encode(cid.toBaseEncodedString, forKey: .link) 146 | } 147 | 148 | enum CodingKeys: String, CodingKey { 149 | case link = "$link" 150 | } 151 | 152 | public init(from decoder: Decoder) throws { 153 | do { 154 | let container = try decoder.container(keyedBy: CodingKeys.self) 155 | let link = try container.decode(String.self, forKey: .link) 156 | self = try CID(link) 157 | } catch { 158 | let container = try decoder.singleValueContainer() 159 | let bytes = try [UInt8](container.decode(Data.self)) 160 | guard bytes[0] == 0 else { 161 | throw error 162 | } 163 | self = try CID(Data(bytes[1...])) 164 | } 165 | } 166 | 167 | public func encode(to encoder: Encoder) throws { 168 | var container = encoder.singleValueContainer() 169 | var bytes: [UInt8] = [0] 170 | bytes.append(contentsOf: rawBuffer) 171 | try container.encode(Data(bytes)) 172 | } 173 | } 174 | 175 | public struct LexBlob: Codable, Sendable { 176 | public let type = "blob" 177 | public let ref: LexLink 178 | public let mimeType: String 179 | public let size: UInt 180 | 181 | public init(original: Self, mimeType: String) { 182 | ref = original.ref 183 | self.mimeType = mimeType 184 | size = original.size 185 | } 186 | 187 | private enum CodingKeys: String, CodingKey { 188 | case type = "$type" 189 | case ref 190 | case mimeType 191 | case size 192 | } 193 | 194 | private enum LegacyCodingKeys: String, CodingKey { 195 | case cid 196 | case mimeType 197 | } 198 | 199 | public init(from decoder: Decoder) throws { 200 | let container = try decoder.container(keyedBy: CodingKeys.self) 201 | if container.allKeys.contains(.ref) { 202 | ref = try container.decode(LexLink.self, forKey: .ref) 203 | mimeType = try container.decode(String.self, forKey: .mimeType) 204 | size = try container.decode(UInt.self, forKey: .size) 205 | } else { 206 | let container = try decoder.container(keyedBy: LegacyCodingKeys.self) 207 | let cid = try container.decode(String.self, forKey: .cid) 208 | ref = try LexLink(cid) 209 | mimeType = try container.decode(String.self, forKey: .mimeType) 210 | size = 0 211 | } 212 | } 213 | } 214 | 215 | public enum ParamElement: Encodable { 216 | case string(String?) 217 | case bool(Bool?) 218 | case integer(Int?) 219 | case array([any Encodable]?) 220 | case unknown(LexiconTypeDecoder?) 221 | 222 | public func encode(to encoder: Encoder) throws { 223 | switch self { 224 | case let .string(value): 225 | try value.encode(to: encoder) 226 | case let .bool(value): 227 | try value.encode(to: encoder) 228 | case let .integer(value): 229 | try value.encode(to: encoder) 230 | case let .array(values): 231 | if let values { 232 | var container = encoder.unkeyedContainer() 233 | for value in values { 234 | try container.encode(value) 235 | } 236 | } 237 | case let .unknown(value): 238 | try value.encode(to: encoder) 239 | } 240 | } 241 | } 242 | 243 | public final class Parameters: Encodable, ExpressibleByDictionaryLiteral { 244 | private let dictionary: [String: ParamElement] 245 | public init(dictionary: [String: ParamElement]) { 246 | self.dictionary = dictionary 247 | } 248 | 249 | public func encode(to encoder: Encoder) throws { 250 | let d = dictionary.filter { 251 | switch $1 { 252 | case let .string(v): 253 | v != nil 254 | case let .bool(v): 255 | v != nil 256 | case let .integer(v): 257 | v != nil 258 | case let .array(v): 259 | v != nil 260 | case let .unknown(v): 261 | v != nil 262 | } 263 | } 264 | try d.encode(to: encoder) 265 | } 266 | 267 | public typealias Key = String 268 | public typealias Value = ParamElement 269 | public required convenience init(dictionaryLiteral elements: (String, ParamElement)...) { 270 | let dictionary = [String: ParamElement](elements, uniquingKeysWith: { l, _ in l }) 271 | self.init(dictionary: dictionary) 272 | } 273 | } 274 | 275 | @inline(never) 276 | @usableFromInline 277 | func _abstract( 278 | file: StaticString = #file, 279 | line: UInt = #line 280 | ) -> Never { 281 | fatalError("Method must be overridden", file: file, line: line) 282 | } 283 | -------------------------------------------------------------------------------- /Sources/SwiftAtproto/URL+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+.swift 3 | // SwiftAtproto 4 | // 5 | // Created by Noriaki Watanabe on 2025/01/16. 6 | // 7 | 8 | import Foundation 9 | 10 | extension CharacterSet { 11 | private static let alpha = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 12 | private static let digit = "0123456789" 13 | private static let hexdig = digit + "ABCDEFabcdef" 14 | private static let unreserved = alpha + digit + "-._~" 15 | static let parameterAllowed = CharacterSet(charactersIn: alpha + digit + "-._") 16 | static let nsidAllowed = CharacterSet(charactersIn: unreserved + "!'()*") 17 | } 18 | 19 | extension URL { 20 | func appending(percentEncodedQueryItems queryItems: [URLQueryItem]) -> URL { 21 | if var c = URLComponents(url: self, resolvingAgainstBaseURL: true) { 22 | var newItems = c.percentEncodedQueryItems ?? [] 23 | newItems.append(contentsOf: queryItems) 24 | c.percentEncodedQueryItems = newItems 25 | if let url = c.url { 26 | return url 27 | } 28 | } 29 | return self 30 | } 31 | 32 | mutating func append(percentEncodedQueryItems queryItems: [URLQueryItem]) { 33 | self = appending(percentEncodedQueryItems: queryItems) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/SwiftAtproto/XRPCClientProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if os(Linux) 3 | import AsyncHTTPClient 4 | import NIOFoundationCompat 5 | import NIOHTTP1 6 | #endif 7 | 8 | public enum HTTPMethod { 9 | case get 10 | case post 11 | } 12 | 13 | public protocol ATPClientProtocol: Sendable { 14 | var serviceEndpoint: URL { get } 15 | var decoder: JSONDecoder { get } 16 | 17 | func getProxy(nsid: String) -> String? 18 | func tokenIsExpired(error: UnExpectedError) -> Bool 19 | func getAuthorization(endpoint: String) -> String? 20 | 21 | mutating func fetch( 22 | endpoint: String, contentType: String, httpMethod: HTTPMethod, params: (some Encodable)?, 23 | input: (some Encodable)?, retry: Bool 24 | ) async throws -> T 25 | mutating func refreshSession() async -> Bool 26 | 27 | static var errorDomain: String { get } 28 | } 29 | 30 | public protocol XRPCClientProtocol: ATPClientProtocol, Sendable { 31 | var auth: any XRPCAuth { get set } 32 | 33 | mutating func signout() 34 | 35 | static func setModuleName() 36 | } 37 | 38 | #if os(Linux) 39 | typealias URLRequest = HTTPClientRequest 40 | extension URLRequest { 41 | init(url: URL) { 42 | self.init(url: url.absoluteString) 43 | } 44 | 45 | mutating func addValue(_ value: String, forHTTPHeaderField field: String) { 46 | headers.add(name: field, value: value) 47 | } 48 | 49 | var httpBody: Data? { 50 | get { 51 | // Not Implemented 52 | nil 53 | } 54 | 55 | set { 56 | guard let newValue else { return } 57 | body = .bytes(newValue) 58 | } 59 | } 60 | 61 | var httpMethod: String? { 62 | get { 63 | method.rawValue 64 | } 65 | set { 66 | guard let newValue else { return } 67 | method = .init(rawValue: newValue) 68 | } 69 | } 70 | } 71 | 72 | extension HTTPClient { 73 | func executeTask(for request: URLRequest) async throws -> (Data, UInt) { 74 | let response = try await execute(request, timeout: .seconds(30)) 75 | let expectedBytes = response.headers.first(name: "content-length").flatMap(Int.init) ?? 1024 * 1024 76 | var body = try await response.body.collect(upTo: expectedBytes) 77 | let data = body.readData(length: body.readableBytes)! 78 | return (data, response.status.code) 79 | } 80 | } 81 | #else 82 | typealias HTTPClient = URLSession 83 | extension HTTPClient { 84 | func executeTask(for request: URLRequest) async throws -> (Data, UInt) { 85 | let (data, response) = try await URLSession.shared.data(for: request) 86 | guard let httpResponse = response as? HTTPURLResponse else { 87 | fatalError() 88 | } 89 | return (data, UInt(httpResponse.statusCode)) 90 | } 91 | } 92 | #endif 93 | 94 | public extension XRPCClientProtocol { 95 | static var errorDomain: String { "XRPCErrorDomain" } 96 | 97 | static func setModuleName() { 98 | LexiconTypesMap.shared.moduleName = _typeName(type(of: self)).split(separator: ".").first.flatMap { String($0) } ?? "" 99 | } 100 | } 101 | 102 | public extension ATPClientProtocol { 103 | func getProxy(nsid _: String) -> String? { nil } 104 | static var errorDomain: String { "ATPErrorDomain" } 105 | 106 | private static func encode(_ string: String, component: XRPCComponent) -> String { 107 | switch component { 108 | case .nsid: 109 | string.addingPercentEncoding(withAllowedCharacters: .nsidAllowed) ?? string 110 | case .parameter: 111 | string.addingPercentEncoding(withAllowedCharacters: .parameterAllowed) ?? string 112 | } 113 | } 114 | 115 | mutating func fetch( 116 | endpoint nsid: String, contentType: String, httpMethod: HTTPMethod, params: (some Encodable)?, input: (some Encodable)?, retry: Bool 117 | ) async throws -> T { 118 | var url = serviceEndpoint.appending(path: Self.encode(nsid, component: .nsid)) 119 | if httpMethod == .get, let params = params?.dictionary { 120 | url.append(percentEncodedQueryItems: Self.makeParameters(params: params)) 121 | } 122 | 123 | var request = URLRequest(url: url) 124 | request.addValue("application/json", forHTTPHeaderField: "Accept") 125 | if let authorization = getAuthorization(endpoint: nsid) { 126 | request.addValue("Bearer \(authorization)", forHTTPHeaderField: "Authorization") 127 | } 128 | if let proxy = getProxy(nsid: nsid) { 129 | request.addValue(proxy, forHTTPHeaderField: "atproto-proxy") 130 | } 131 | switch httpMethod { 132 | case .get: 133 | request.httpMethod = "GET" 134 | case .post: 135 | request.httpMethod = "POST" 136 | request.addValue(contentType, forHTTPHeaderField: "Content-Type") 137 | if let input { 138 | let encoder = JSONEncoder() 139 | encoder.dataEncodingStrategy = Self.dataEncodingStrategy 140 | encoder.outputFormatting = [.withoutEscapingSlashes] 141 | let body: Data = switch input { 142 | case let data as Data: 143 | data 144 | default: 145 | try encoder.encode(input) 146 | } 147 | request.httpBody = body 148 | request.addValue("\(body.count)", forHTTPHeaderField: "Content-Length") 149 | } 150 | } 151 | 152 | let (data, statusCode) = try await HTTPClient.shared.executeTask(for: request) 153 | 154 | guard 200 ... 299 ~= statusCode else { 155 | if let error = try? decoder.decode(UnExpectedError.self, from: data) { 156 | if tokenIsExpired(error: error), retry, await refreshSession() { 157 | return try await fetch( 158 | endpoint: Self.encode(nsid, component: .nsid), contentType: contentType, httpMethod: httpMethod, 159 | params: params, input: input, retry: false 160 | ) 161 | } 162 | throw error 163 | } else { 164 | let message = String(decoding: data, as: UTF8.self) 165 | throw NSError(domain: Self.errorDomain, code: 0, userInfo: [NSLocalizedDescriptionKey: "Server error: \(message)(\(statusCode))"]) 166 | } 167 | } 168 | 169 | if T.self == Bool.self { 170 | return true as! T 171 | } 172 | if T.self == Data.self { 173 | return data as! T 174 | } 175 | return try decoder.decode(T.self, from: data) 176 | } 177 | 178 | static func makeParameters(params: [String: Any]) -> [URLQueryItem] { 179 | var items = [URLQueryItem]() 180 | for param in params { 181 | if let seq = param.value as? [String] { 182 | items.append(contentsOf: seq.map { URLQueryItem(name: encode(param.key, component: .parameter), value: encode($0, component: .parameter)) }) 183 | } else { 184 | items.append(URLQueryItem(name: encode(param.key, component: .parameter), value: encode("\(param.value)", component: .parameter))) 185 | } 186 | } 187 | return items 188 | } 189 | 190 | internal static var dataEncodingStrategy: JSONEncoder.DataEncodingStrategy { 191 | .custom { data, encoder in 192 | do { 193 | if !data.isEmpty, data[0] == 0 { 194 | try LexLink.dataEncodingStrategy(data: data, encoder: encoder) 195 | return 196 | } 197 | } catch {} 198 | if let string = String(data: data, encoding: .utf8) { 199 | try string.encode(to: encoder) 200 | } else { 201 | try data.base64Encoded().encode(to: encoder) 202 | } 203 | } 204 | } 205 | } 206 | 207 | public protocol XRPCError: Error, LocalizedError, Decodable, Sendable { 208 | var error: String? { get } 209 | var message: String? { get } 210 | } 211 | 212 | public extension XRPCError { 213 | var errorDescription: String? { 214 | message 215 | } 216 | } 217 | 218 | public final class UnExpectedError: XRPCError { 219 | public let error: String? 220 | public let message: String? 221 | public init(error: String?, message: String?) { 222 | self.error = error 223 | self.message = message 224 | } 225 | } 226 | 227 | public struct UnknownRecord: Identifiable, Codable, Sendable { 228 | public let type: String 229 | public var _unknownValues: [String: AnyCodable] 230 | 231 | enum CodingKeys: String, CodingKey { 232 | case type = "$type" 233 | } 234 | 235 | public var id: String { UUID().uuidString } 236 | 237 | public init(type: String) { 238 | self.type = type 239 | _unknownValues = [:] 240 | } 241 | 242 | public init(from decoder: any Decoder) throws { 243 | let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) 244 | type = try keyedContainer.decode(String.self, forKey: .type) 245 | let unknownContainer = try decoder.container(keyedBy: AnyCodingKeys.self) 246 | var _unknownValues = [String: AnyCodable]() 247 | for key in unknownContainer.allKeys { 248 | guard CodingKeys(rawValue: key.stringValue) == nil else { 249 | continue 250 | } 251 | _unknownValues[key.stringValue] = try unknownContainer.decode(AnyCodable.self, forKey: key) 252 | } 253 | self._unknownValues = _unknownValues 254 | } 255 | 256 | public func encode(to encoder: any Encoder) throws { 257 | var container = encoder.container(keyedBy: CodingKeys.self) 258 | try container.encode(type, forKey: .type) 259 | try _unknownValues.encode(to: encoder) 260 | } 261 | } 262 | 263 | enum XRPCComponent { 264 | case nsid 265 | case parameter 266 | } 267 | -------------------------------------------------------------------------------- /Sources/SwiftAtproto/extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Encodable { 4 | var dictionary: [String: Any]? { 5 | guard let data = try? JSONEncoder().encode(self) else { return nil } 6 | return (try? JSONSerialization.jsonObject(with: data, options: .allowFragments)).flatMap { 7 | $0 as? [String: Any] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/SwiftAtprotoLex/SwiftAtprotoLex.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftSyntax 3 | import SwiftSyntaxBuilder 4 | 5 | public func main(outdir: String, path: String) throws { 6 | let decoder = JSONDecoder() 7 | var schemas = [Schema]() 8 | let url = URL(filePath: path) 9 | var prefixes = Set() 10 | if let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) { 11 | for case let fileUrl as URL in enumerator { 12 | do { 13 | let fileAttributes = try fileUrl.resourceValues(forKeys: [.isRegularFileKey]) 14 | if fileAttributes.isRegularFile!, fileUrl.pathExtension == "json" { 15 | prefixes.insert(fileUrl.prefix(baseURL: url)) 16 | let json = try String(contentsOf: fileUrl, encoding: .utf8) 17 | try schemas.append(decoder.decode(Schema.self, from: Data(json.utf8))) 18 | } 19 | } catch { 20 | print(error, fileUrl) 21 | } 22 | } 23 | } 24 | let defmap = Lex.buildExtDefMap(schemas: schemas, prefixes: prefixes) 25 | let outdirBaseURL = URL(filePath: outdir) 26 | for prefix in prefixes { 27 | let filePrefix = prefix.split(separator: ".").joined() 28 | let outdirURL = outdirBaseURL.appending(path: filePrefix) 29 | if FileManager.default.fileExists(atPath: outdirURL.path) { 30 | try FileManager.default.removeItem(at: outdirURL) 31 | } 32 | try FileManager.default.createDirectory(at: outdirURL, withIntermediateDirectories: true) 33 | let enumName = Lex.structNameFor(prefix: prefix) 34 | let fileUrl = outdirURL.appending(path: "\(enumName).swift") 35 | let src = Lex.baseFile(prefix: prefix) 36 | try src.write(to: fileUrl, atomically: true, encoding: .utf8) 37 | for schema in schemas { 38 | guard schema.id.hasPrefix(prefix) else { continue } 39 | let fileUrl = outdirURL.appending(path: "\(filePrefix)_\(schema.name).swift") 40 | let src = Lex.genCode(for: schema, prefix: prefix, defMap: defmap) 41 | try src.write(to: fileUrl, atomically: true, encoding: .utf8) 42 | } 43 | } 44 | } 45 | 46 | private extension URL { 47 | func prefix(baseURL: URL) -> String { 48 | precondition(path.hasPrefix(baseURL.path)) 49 | let relativeCount = pathComponents.count - baseURL.pathComponents.count 50 | let url = relativeCount >= 4 ? deletingLastPathComponent() : self 51 | return url.deletingLastPathComponent().path.dropFirst(baseURL.path.count + 1).replacingOccurrences(of: "/", with: ".") 52 | } 53 | } 54 | 55 | enum Lex { 56 | private static var fileHeader: Trivia { 57 | Trivia(pieces: [ 58 | .lineComment("//"), 59 | .newlines(1), 60 | .lineComment("// DO NOT EDIT"), 61 | .newlines(1), 62 | .lineComment("//"), 63 | .newlines(1), 64 | .lineComment("// Generated by swift-atproto"), 65 | .newlines(1), 66 | .lineComment("//"), 67 | .newlines(2), 68 | ]) 69 | } 70 | 71 | static func baseFile(prefix: String) -> String { 72 | let src = SourceFileSyntax(leadingTrivia: Self.fileHeader, statementsBuilder: { 73 | ImportDeclSyntax( 74 | path: ImportPathComponentListSyntax([ImportPathComponentSyntax(name: "SwiftAtproto")]), 75 | trailingTrivia: .newlines(2) 76 | ) 77 | EnumDeclSyntax( 78 | modifiers: [ 79 | DeclModifierSyntax(name: .keyword(.public)), 80 | ], 81 | name: .identifier(Lex.structNameFor(prefix: prefix)), 82 | memberBlock: MemberBlockSyntax( 83 | leftBrace: .leftBraceToken(), 84 | members: MemberBlockItemListSyntax([]), 85 | rightBrace: .rightBraceToken() 86 | ) 87 | ) 88 | }, 89 | trailingTrivia: .newline) 90 | return src.formatted().description 91 | } 92 | 93 | static func genCode(for schema: Schema, prefix: String, defMap: ExtDefMap) -> String { 94 | schema.prefix = prefix 95 | let structName = Lex.structNameFor(prefix: prefix) 96 | let allTypes = schema.allTypes(prefix: prefix).sorted(by: { 97 | $0.key.localizedStandardCompare($1.key) == .orderedAscending 98 | }) 99 | let recordTypes = allTypes.filter(\.value.isRecord) 100 | let otherTypes = allTypes.filter { !$0.value.isRecord } 101 | let methods: [DeclSyntaxProtocol]? = if let main = schema.defs["main"], 102 | main.isMethod 103 | { 104 | Self.writeMethods( 105 | leadingTrivia: otherTypes.isEmpty ? nil : .newlines(2), 106 | typeName: Self.nameFromId(id: schema.id, prefix: prefix), 107 | typeSchema: main, 108 | defMap: defMap, 109 | prefix: structNameFor(prefix: prefix) 110 | ) 111 | } else { 112 | nil 113 | } 114 | let enumExtensionIsNeeded = !otherTypes.isEmpty || methods != nil 115 | let src = SourceFileSyntax(leadingTrivia: Self.fileHeader, statementsBuilder: { 116 | ImportDeclSyntax( 117 | path: ImportPathComponentListSyntax([ImportPathComponentSyntax(name: "SwiftAtproto")]) 118 | ) 119 | ImportDeclSyntax( 120 | path: ImportPathComponentListSyntax([ImportPathComponentSyntax(name: "Foundation")]), 121 | trailingTrivia: .newlines(2) 122 | ) 123 | if enumExtensionIsNeeded { 124 | ExtensionDeclSyntax(extendedType: TypeSyntax(stringLiteral: structName)) { 125 | for (i, (name, ot)) in otherTypes.enumerated() { 126 | ot.lex(leadingTrivia: i == 0 ? nil : .newlines(2), name: name, type: (ot.defName.isEmpty || ot.defName == "main") ? ot.id : "\(ot.id)#\(ot.defName)", defMap: defMap) 127 | } 128 | 129 | if let methods, methods.count == 2 { 130 | methods[0] 131 | } 132 | } 133 | } 134 | if let methods, let method = methods.last { 135 | ExtensionDeclSyntax(extendedType: TypeSyntax(stringLiteral: "XRPCClientProtocol")) { 136 | method 137 | } 138 | } 139 | for (i, (name, ot)) in recordTypes.enumerated() { 140 | ot.lex(leadingTrivia: (!enumExtensionIsNeeded && i == 0) ? nil : .newlines(2), name: name, type: (ot.defName.isEmpty || ot.defName == "main") ? ot.id : "\(ot.id)#\(ot.defName)", defMap: defMap) 141 | } 142 | }, 143 | trailingTrivia: .newline) 144 | return src.formatted().description 145 | } 146 | 147 | static func writeMethods(leadingTrivia: Trivia? = nil, typeName: String, typeSchema ts: TypeSchema, defMap: ExtDefMap, prefix: String) -> [DeclSyntaxProtocol]? { 148 | switch ts.type { 149 | case .token: 150 | let n: String = if ts.defName == "main" { 151 | ts.id 152 | } else { 153 | "\(ts.id)#\(ts.defName)" 154 | } 155 | let variable = VariableDeclSyntax( 156 | leadingTrivia: leadingTrivia, 157 | modifiers: [ 158 | DeclModifierSyntax(name: .keyword(.public)), 159 | ], 160 | bindingSpecifier: .keyword(.let) 161 | ) { 162 | PatternBindingSyntax( 163 | pattern: PatternSyntax(stringLiteral: typeName), 164 | initializer: InitializerClauseSyntax( 165 | value: StringLiteralExprSyntax(content: n) 166 | ) 167 | ) 168 | } 169 | return [variable] 170 | case let .procedure(def as HTTPAPITypeDefinition), .query(let def as HTTPAPITypeDefinition): 171 | return [ 172 | ts.writeErrorDecl(leadingTrivia: leadingTrivia, def: def, typeName: typeName, defMap: defMap), 173 | ts.writeRPC(leadingTrivia: nil, def: def, typeName: typeName, defMap: defMap, prefix: prefix), 174 | ].compactMap(\.self) 175 | default: 176 | return nil 177 | } 178 | } 179 | 180 | static func buildExtDefMap(schemas: [Schema], prefixes: Set) -> ExtDefMap { 181 | var out = ExtDefMap() 182 | for schema in schemas { 183 | for (defName, def) in schema.defs { 184 | def.id = schema.id 185 | def.defName = defName 186 | 187 | def.prefix = { 188 | for p in prefixes { 189 | if schema.id.hasPrefix(p) { 190 | return p 191 | } 192 | } 193 | return "" 194 | }() 195 | 196 | let key = { 197 | if defName == "main" { 198 | return schema.id 199 | } 200 | return "\(schema.id)#\(defName)" 201 | }() 202 | out[key] = ExtDef(type: def) 203 | } 204 | } 205 | return out 206 | } 207 | 208 | static func nameFromId(id: String, prefix: String) -> String { 209 | id.trim(prefix: prefix).split(separator: ".").map { 210 | $0.titleCased() 211 | }.joined() 212 | } 213 | 214 | static func structNameFor(prefix: String) -> String { 215 | "\(prefix.split(separator: ".").joined())types" 216 | } 217 | 218 | static func caseNameFromId(id: String, prefix: String) -> String { 219 | id.trim(prefix: "\(prefix).").components(separatedBy: CharacterSet(charactersIn: ".#")).enumerated().map { 220 | $0 == 0 ? $1 : $1.titleCased() 221 | }.joined() 222 | } 223 | } 224 | 225 | extension String { 226 | func trim(prefix: String) -> String { 227 | guard hasPrefix(prefix) else { return self } 228 | return String(dropFirst(prefix.count)) 229 | } 230 | 231 | func titleCased() -> String { 232 | var prev = Character(" ") 233 | return String(map { 234 | if prev.isWhitespace { 235 | prev = $0 236 | return Character($0.uppercased()) 237 | } 238 | prev = $0 239 | return $0 240 | }) 241 | } 242 | 243 | func camelCased() -> String { 244 | guard !isEmpty else { return "" } 245 | let words = components(separatedBy: CharacterSet.alphanumerics.inverted).filter { !$0.isEmpty } 246 | let first = words.first!.lowercased() 247 | let rest = words.dropFirst().map(\.capitalized) 248 | return ([first] + rest).joined() 249 | } 250 | } 251 | 252 | extension Substring { 253 | func titleCased() -> String { 254 | var prev = Character(" ") 255 | return String(map { 256 | if prev.isWhitespace { 257 | prev = $0 258 | return Character($0.uppercased()) 259 | } 260 | prev = $0 261 | return $0 262 | }) 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /Sources/SwiftAtprotoLex/misc.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | 3 | private let keywords: [String] = [ 4 | "Any", 5 | "as", 6 | "associatedtype", 7 | "break", 8 | "case", 9 | "catch", 10 | "class", 11 | "continue", 12 | "default", 13 | "defer", 14 | "deinit", 15 | "do", 16 | "else", 17 | "enum", 18 | "extension", 19 | "fallthrough", 20 | "false", 21 | "fileprivate", 22 | "for", 23 | "func", 24 | "guard", 25 | "if", 26 | "import", 27 | "in", 28 | "init", 29 | "inout", 30 | "internal", 31 | "is", 32 | "let", 33 | "nil", 34 | "operator", 35 | "precedencegroup", 36 | "private", 37 | "Protocol", 38 | "protocol", 39 | "public", 40 | "repeat", 41 | "rethrows", 42 | "return", 43 | "self", 44 | "Self", 45 | "static", 46 | "struct", 47 | "subscript", 48 | "super", 49 | "switch", 50 | "throw", 51 | "throws", 52 | "true", 53 | "try", 54 | "Type", 55 | "typealias", 56 | "var", 57 | "where", 58 | "while", 59 | ] 60 | func isNeedEscapingKeyword(_ string: String) -> Bool { 61 | keywords.contains(string) 62 | } 63 | -------------------------------------------------------------------------------- /SwiftAtproto.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = "SwiftAtproto" 3 | s.version = "0.4.2" 4 | s.summary = "swift-atproto is a atproto library." 5 | s.homepage = "https://github.com/nnabeyang/swift-atproto" 6 | s.license = { :type => "MIT", :file => "LICENSE" } 7 | s.author = { "nnabeyang" => "nabeyang@gmail.com" } 8 | s.ios.deployment_target = "16.0" 9 | s.osx.deployment_target = "13.0" 10 | 11 | s.source = { :git => "https://github.com/nnabeyang/swift-atproto.git", :tag => "#{s.version}" } 12 | s.source_files = "Sources/SwiftAtproto/*.swift" 13 | s.requires_arc = true 14 | s.swift_version = '5.9' 15 | end 16 | -------------------------------------------------------------------------------- /Tests/MacrosTests/MacrosTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MacrosTests.swift 3 | // SwiftAtproto 4 | // 5 | // Created by Noriaki Watanabe on 2024/12/23. 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | import SwiftSyntaxMacros 11 | import SwiftSyntaxMacrosTestSupport 12 | import XCTest 13 | @testable import Macros 14 | 15 | let testMacros: [String: Macro.Type] = [ 16 | "XRPCClientMacro": XRPCClientMacro.self, 17 | ] 18 | 19 | final class MacroExampleTests: XCTestCase { 20 | func testXRPCClientMacro() throws { 21 | assertMacroExpansion( 22 | """ 23 | @XRPCClientMacro 24 | public struct TestClient { 25 | let host: URL 26 | public var auth: AuthInfo 27 | public var serviceEndpoint: URL { 28 | auth.serviceEndPoint ?? host 29 | } 30 | public let decoder: JSONDecoder 31 | } 32 | """, 33 | expandedSource: """ 34 | public struct TestClient { 35 | let host: URL 36 | public var auth: AuthInfo 37 | public var serviceEndpoint: URL { 38 | auth.serviceEndPoint ?? host 39 | } 40 | public let decoder: JSONDecoder 41 | 42 | private init(host: URL, auth: AuthInfo) { 43 | self.host = host 44 | self.auth = auth 45 | decoder = JSONDecoder() 46 | Self.setModuleName() 47 | } 48 | } 49 | 50 | extension TestClient: XRPCClientProtocol { 51 | } 52 | """, 53 | macros: testMacros, 54 | indentationWidth: .spaces(2) 55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Tests/SwiftAtprotoTests/SwiftAtprotoTests.swift: -------------------------------------------------------------------------------- 1 | import CID 2 | import Foundation 3 | import Multicodec 4 | import Multihash 5 | import XCTest 6 | @testable import SwiftAtproto 7 | 8 | struct XRPCTestClient: XRPCClientProtocol { 9 | var serviceEndpoint: URL 10 | 11 | var decoder: JSONDecoder 12 | 13 | var auth: any XRPCAuth 14 | 15 | func tokenIsExpired(error _: SwiftAtproto.UnExpectedError) -> Bool { 16 | fatalError() 17 | } 18 | 19 | func getAuthorization(endpoint _: String) -> String? { 20 | fatalError() 21 | } 22 | 23 | func refreshSession() async -> Bool { 24 | fatalError() 25 | } 26 | 27 | func signout() { 28 | fatalError() 29 | } 30 | } 31 | 32 | final class SwiftAtprotoTests: XCTestCase { 33 | func testURLAppendPercentEncodedQueryItems() { 34 | var url = URL(string: "https://example.com")! 35 | url.append(percentEncodedQueryItems: [.init(name: #"%3B%2C%2F%3F%3A%40%26%3D%2B%24%23-"#, value: #"_.%21%7E%2A%27%28%29%5B%5D"#)]) 36 | XCTAssertEqual(url.absoluteString, "https://example.com?%3B%2C%2F%3F%3A%40%26%3D%2B%24%23-=_.%21%7E%2A%27%28%29%5B%5D") 37 | } 38 | 39 | func testMakeParameters() throws { 40 | let items = XRPCTestClient.makeParameters(params: ["param1[]": ["1", "2", "3"], "param2": "hello", ";,/?:@&=+$#-_.!~*'()[]": ";,/?:@&=+$#-_.!~*'()[]"]) 41 | XCTAssertEqual(items.map(\.description).sorted(), [#"%3B%2C%2F%3F%3A%40%26%3D%2B%24%23-_.%21%7E%2A%27%28%29%5B%5D=%3B%2C%2F%3F%3A%40%26%3D%2B%24%23-_.%21%7E%2A%27%28%29%5B%5D"#, "param1%5B%5D=1", "param1%5B%5D=2", "param1%5B%5D=3", "param2=hello"]) 42 | } 43 | 44 | func testLexLinkCodable() throws { 45 | let json = #"{"$link":"bafkreibxn7ww5xcnkgwii3cndu46ike6reqllouvapfevfrxscpm3ovveq"}"# 46 | let decoder = JSONDecoder() 47 | let link = try decoder.decode(LexLink.self, from: Data(json.utf8)) 48 | let cid = try CID(version: .v1, codec: .raw, multihash: Multihash(raw: "cids(1)", hashedWith: .sha2_256)) 49 | XCTAssertEqual(link.toBaseEncodedString, cid.toBaseEncodedString) 50 | let encoder = JSONEncoder() 51 | encoder.dataEncodingStrategy = XRPCTestClient.dataEncodingStrategy 52 | XCTAssertEqual(try String(decoding: encoder.encode(link), as: UTF8.self), json) 53 | } 54 | 55 | func testLexBlobCodable() throws { 56 | let json = #"{"$type":"blob","mimeType":"image/jpeg","ref":{"$link":"bafkreibme22gw2h7y2h7tg2fhqotaqjucnbc24deqo72b6mkl2egezxhvy"},"size":1234}"# 57 | let decoder = JSONDecoder() 58 | let blob = try decoder.decode(LexBlob.self, from: Data(json.utf8)) 59 | XCTAssertEqual(blob.ref.toBaseEncodedString, "bafkreibme22gw2h7y2h7tg2fhqotaqjucnbc24deqo72b6mkl2egezxhvy") 60 | let encoder = JSONEncoder() 61 | encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] 62 | encoder.dataEncodingStrategy = XRPCTestClient.dataEncodingStrategy 63 | XCTAssertEqual(try String(decoding: encoder.encode(blob), as: UTF8.self), json) 64 | } 65 | 66 | func testLexBlobCodableLegacyCase() throws { 67 | let json = #"{"$type":"blob","mimeType":"image/jpeg","ref":{"$link":"bafkreibme22gw2h7y2h7tg2fhqotaqjucnbc24deqo72b6mkl2egezxhvy"},"size":0}"# 68 | let legacyJson = #"{"cid": "bafkreibme22gw2h7y2h7tg2fhqotaqjucnbc24deqo72b6mkl2egezxhvy","mimeType": "image/jpeg"}"# 69 | let decoder = JSONDecoder() 70 | let blob = try decoder.decode(LexBlob.self, from: Data(legacyJson.utf8)) 71 | XCTAssertEqual(blob.ref.toBaseEncodedString, "bafkreibme22gw2h7y2h7tg2fhqotaqjucnbc24deqo72b6mkl2egezxhvy") 72 | let encoder = JSONEncoder() 73 | encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] 74 | encoder.dataEncodingStrategy = XRPCTestClient.dataEncodingStrategy 75 | XCTAssertEqual(try String(decoding: encoder.encode(blob), as: UTF8.self), json) 76 | } 77 | 78 | func testUnknownRecordCodable() throws { 79 | let json = #""" 80 | { 81 | "$type" : "app.bsky.actor.defs#skyfeedBuilderFeedsPref", 82 | "key1" : { 83 | "nest_key1" : 123 84 | }, 85 | "key2" : [ 86 | { 87 | "nest_key2" : "abc", 88 | "nest_key3" : true 89 | }, 90 | { 91 | "nest_key2" : "xyz", 92 | "nest_key3" : false 93 | } 94 | ] 95 | } 96 | """# 97 | let decoder = JSONDecoder() 98 | let record = try decoder.decode(UnknownRecord.self, from: Data(json.utf8)) 99 | XCTAssertEqual(record.type, "app.bsky.actor.defs#skyfeedBuilderFeedsPref") 100 | let encoder = JSONEncoder() 101 | encoder.dataEncodingStrategy = XRPCTestClient.dataEncodingStrategy 102 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] 103 | XCTAssertEqual(try String(decoding: encoder.encode(record), as: UTF8.self), json) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | build: 4 | context: ./ 5 | dockerfile: Dockerfile 6 | args: 7 | - USERNAME=user 8 | - GROUPNAME=user 9 | - UID=1000 10 | - GID=1000 11 | command: sleep infinity 12 | environment: 13 | - LANG=C.UTF-8 14 | volumes: 15 | - ".:/home/user/work" 16 | -------------------------------------------------------------------------------- /format.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | swift package plugin --allow-writing-to-package-directory swiftformat . 3 | --------------------------------------------------------------------------------