Skip to content

Instantly share code, notes, and snippets.

@g-cqd
Last active December 29, 2025 16:10
Show Gist options
  • Select an option

  • Save g-cqd/026b1bdef9abf3a40ce477b3c4a10db4 to your computer and use it in GitHub Desktop.

Select an option

Save g-cqd/026b1bdef9abf3a40ce477b3c4a10db4 to your computer and use it in GitHub Desktop.
Swift Package Development: Comprehensive Knowledge Guide

Swift Package Development: Comprehensive Knowledge Guide

A collection of best practices, patterns, and solutions gathered from building production Swift packages.

Table of Contents

  1. Package Structure
  2. Swift 6 & Strict Concurrency
  3. CLI Tools with ArgumentParser
  4. Build Plugins
  5. DocC Documentation
  6. Universal Binary Distribution
  7. Code Quality Tooling
  8. Static Analysis Patterns
  9. Testing Strategies
  10. Performance Optimization
  11. GitHub Actions CI/CD

Package Structure

Recommended Layout

MyPackage/
├── Package.swift
├── Sources/
│   ├── MyPackageCore/           # Core library (no dependencies)
│   │   ├── Models/
│   │   ├── Visitors/
│   │   └── MyPackageCore.docc/  # DocC catalog
│   ├── FeatureModule/           # Feature-specific module
│   └── MyPackageCLI/            # CLI executable
├── Plugins/
│   ├── MyBuildPlugin/
│   └── MyCommandPlugin/
├── Tests/
│   ├── MyPackageCoreTests/
│   └── FeatureModuleTests/
├── .swiftlint.yml
├── .swiftformat
├── CLAUDE.md                    # AI assistant instructions
└── README.md

Package.swift Best Practices

// swift-tools-version: 6.2

import PackageDescription

let package = Package(
    name: "MyPackage",
    platforms: [
        .macOS(.v15),
        .iOS(.v18)
    ],
    products: [
        .library(name: "MyPackageCore", targets: ["MyPackageCore"]),
        .executable(name: "mytool", targets: ["MyPackageCLI"]),
        .plugin(name: "MyBuildPlugin", targets: ["MyBuildPlugin"]),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.7.0"),
        .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0"),
        .package(url: "https://github.com/swiftlang/swift-docc-plugin.git", from: "1.4.5"),
    ],
    targets: [
        .target(
            name: "MyPackageCore",
            dependencies: [
                .product(name: "SwiftSyntax", package: "swift-syntax"),
                .product(name: "SwiftParser", package: "swift-syntax"),
            ],
            swiftSettings: [
                .swiftLanguageMode(.v6),
                .enableExperimentalFeature("StrictConcurrency"),
            ]
        ),
        // ... more targets
    ]
)

Swift 6 & Strict Concurrency

Key Principles

  1. Default to @MainActor for UI and ViewModels
  2. Use actor for shared mutable state
  3. All cross-boundary types must be Sendable
  4. No GCD - use Task, TaskGroup, AsyncStream

Sendable Patterns

// Value types are automatically Sendable
struct SourceLocation: Sendable {
    let file: String
    let line: Int
    let column: Int
}

// Reference types need explicit conformance
final class Configuration: Sendable {
    let minConfidence: Double  // Immutable = Sendable
    
    init(minConfidence: Double) {
        self.minConfidence = minConfidence
    }
}

// Actors are inherently Sendable
actor AnalysisEngine {
    private var cache: [String: AnalysisResult] = [:]
    
    func analyze(file: String) async throws -> AnalysisResult {
        if let cached = cache[file] { return cached }
        let result = try await performAnalysis(file)
        cache[file] = result
        return result
    }
}

Concurrent Processing

// Process files in parallel with controlled concurrency
func processFiles(_ files: [URL]) async throws -> [Result] {
    try await withThrowingTaskGroup(of: Result.self) { group in
        for file in files {
            group.addTask {
                try await self.process(file)
            }
        }
        
        var results: [Result] = []
        for try await result in group {
            results.append(result)
        }
        return results
    }
}

CLI Tools with ArgumentParser

Basic Command Structure

import ArgumentParser

@main
struct MyCLI: AsyncParsableCommand {
    static let configuration = CommandConfiguration(
        commandName: "mytool",
        abstract: "A tool for doing things",
        version: "1.0.0",
        subcommands: [Analyze.self, Format.self],
        defaultSubcommand: Analyze.self
    )
}

Subcommand with Validated Arguments

struct Analyze: AsyncParsableCommand {
    static let configuration = CommandConfiguration(
        abstract: "Analyze source files"
    )
    
    @Argument(help: "Path to analyze")
    var path: String
    
    @Option(name: .shortAndLong, help: "Output format")
    var format: OutputFormat = .text
    
    @Option(name: .long, parsing: .upToNextOption, help: "Types to detect")
    var types: [DetectionType] = DetectionType.allCases
    
    @Flag(name: .long, help: "Use sensible defaults")
    var sensibleDefaults = false
    
    func run() async throws {
        // Implementation
    }
}

// Type-safe enum arguments with validation
enum OutputFormat: String, ExpressibleByArgument, CaseIterable {
    case text, json, xcode
}

enum DetectionType: String, ExpressibleByArgument, CaseIterable {
    case exact, near, semantic
    
    // Custom init for better error messages
    init?(argument: String) {
        guard let value = Self(rawValue: argument.lowercased()) else {
            return nil
        }
        self = value
    }
    
    static var allValueStrings: [String] {
        allCases.map(\.rawValue)
    }
}

Repeatable Options

// For options that can be specified multiple times:
// mytool --exclude "Tests/**" --exclude "Fixtures/**"
@Option(name: .long, parsing: .upToNextOption, help: "Paths to exclude")
var excludePaths: [String] = []

Build Plugins

Build Tool Plugin

import PackagePlugin

@main
struct MyBuildPlugin: BuildToolPlugin {
    func createBuildCommands(
        context: PluginContext,
        target: Target
    ) async throws -> [Command] {
        guard let sourceTarget = target as? SourceModuleTarget else {
            return []
        }
        
        let tool = try context.tool(named: "mytool")
        let sourceFiles = sourceTarget.sourceFiles
            .filter { $0.url.pathExtension == "swift" }
            .map(\.url)
        
        guard !sourceFiles.isEmpty else { return [] }
        
        let outputDir = context.pluginWorkDirectoryURL
            .appendingPathComponent("output")
        
        return [
            .prebuildCommand(
                displayName: "MyTool \(target.name)",
                executable: tool.url,
                arguments: ["analyze"] + sourceFiles.map(\.path),
                outputFilesDirectory: outputDir
            )
        ]
    }
}

Finding System-Installed Tools

Build plugins should check PATH before downloading:

/// Check PATH before downloading binaries
private func findInPath(_ executable: String) -> URL? {
    let searchPaths = [
        "/opt/homebrew/bin",
        "/usr/local/bin",
        "/usr/bin",
        "/bin",
    ]

    let envPath = ProcessInfo.processInfo.environment["PATH"] ?? ""
    let allPaths = searchPaths + envPath.split(separator: ":").map(String.init)

    for dir in allPaths {
        let fullPath = URL(fileURLWithPath: dir).appendingPathComponent(executable)
        if FileManager.default.isExecutableFile(atPath: fullPath.path) {
            return fullPath
        }
    }
    return nil
}

// Usage in plugin
func ensureTool(in workDir: URL) async throws -> URL {
    // First check if tool is installed system-wide
    if let systemPath = findInPath("mytool") {
        Diagnostics.remark("Using system-installed mytool at \(systemPath.path)")
        return systemPath
    }

    // Fall back to downloading
    return try await downloadTool(to: workDir)
}

CI-Safe SwiftLint Arguments

SwiftLint may fail in CI with cache permission errors. Use these flags:

// In your build plugin
var arguments = [
    "lint",
    "--quiet",
    "--no-cache",        // Prevents cache permission errors in CI sandboxes
    "--force-exclude",   // Ensures exclusions work with direct file paths
    "--reporter", "xcode"
]

// Add config file if found
if let configPath = findConfigFile(in: packageDir) {
    arguments += ["--config", configPath.path]
}

// Add source files
arguments += sourceFiles.map(\.path)

Key flags:

  • --no-cache: Prevents SwiftLint from writing to cache directories (may be read-only in CI)
  • --force-exclude: Ensures .swiftlint.yml exclusions are respected with direct file arguments

Command Plugin

import PackagePlugin

@main
struct MyCommandPlugin: CommandPlugin {
    func performCommand(
        context: PluginContext,
        arguments: [String]
    ) async throws {
        let tool = try context.tool(named: "mytool")
        
        let process = Process()
        process.executableURL = tool.url
        process.arguments = arguments
        
        try process.run()
        process.waitUntilExit()
        
        guard process.terminationStatus == 0 else {
            throw PluginError.commandFailed(process.terminationStatus)
        }
    }
}

DocC Documentation

Catalog Structure

Sources/MyPackage/MyPackage.docc/
├── MyPackage.md                    # Landing page
├── GettingStarted.md              # Quick start guide
├── Articles/
│   ├── CoreConcepts.md
│   ├── AdvancedUsage.md
│   └── CIIntegration.md
└── Resources/
    └── diagram.png

Landing Page Template

# ``MyPackage``

Brief description of your package.

## Overview

Longer description with key features:

- **Feature 1**: Description
- **Feature 2**: Description
- **Feature 3**: Description

## Topics

### Essentials

- <doc:GettingStarted>
- <doc:CoreConcepts>

### Configuration

- <doc:AdvancedUsage>
- <doc:CIIntegration>

### Core Types

- ``MainType``
- ``Configuration``
- ``Result``

Building Documentation

# Local preview
swift package --disable-sandbox preview-documentation --target MyPackage

# Generate for static hosting
swift package --allow-writing-to-directory ./docs \
    generate-documentation \
    --target MyPackage \
    --disable-indexing \
    --transform-for-static-hosting \
    --hosting-base-path MyPackage \
    --output-path ./docs

GitHub Pages Workflow

docs:
  runs-on: macos-15
  steps:
    - uses: actions/checkout@v4
    - run: sudo xcode-select -s /Applications/Xcode_26.1.1.app
    - run: |
        swift package --allow-writing-to-directory ./docs \
          generate-documentation \
          --target MyPackage \
          --transform-for-static-hosting \
          --hosting-base-path MyPackage \
          --output-path ./docs
    - uses: actions/upload-pages-artifact@v3
      with:
        path: ./docs

deploy:
  needs: docs
  runs-on: ubuntu-latest
  environment:
    name: github-pages
  steps:
    - uses: actions/deploy-pages@v4

Universal Binary Distribution

Building Universal Binaries

# Build for each architecture
swift build -c release --arch arm64
mv .build/release/mytool .build/release/mytool-arm64

swift build -c release --arch x86_64
mv .build/release/mytool .build/release/mytool-x86_64

# Create universal binary
lipo -create -output .build/release/mytool \
    .build/release/mytool-arm64 \
    .build/release/mytool-x86_64

# Verify
lipo -info .build/release/mytool
# Output: Architectures in the fat file: mytool are: x86_64 arm64

Release Workflow

- name: Build Universal Binary
  run: |
    swift build -c release --arch arm64
    mv .build/release/mytool .build/release/mytool-arm64
    
    swift build -c release --arch x86_64
    mv .build/release/mytool .build/release/mytool-x86_64
    
    lipo -create -output .build/release/mytool \
      .build/release/mytool-arm64 \
      .build/release/mytool-x86_64

- name: Package
  run: |
    VERSION="${{ needs.prepare.outputs.version }}"
    mkdir release
    
    for ARCH in universal arm64 x86_64; do
      if [[ "$ARCH" == "universal" ]]; then
        cp .build/release/mytool release/mytool
      else
        cp .build/release/mytool-$ARCH release/mytool
      fi
      tar -C release -czvf "release/mytool-${VERSION}-macos-${ARCH}.tar.gz" mytool
    done
    
    cd release && shasum -a 256 *.tar.gz > checksums.txt

Code Quality Tooling

SwiftLint Configuration (.swiftlint.yml)

# Enabled rules
opt_in_rules:
  - array_init
  - closure_spacing
  - contains_over_filter_count
  - empty_collection_literal
  - explicit_init
  - first_where
  - last_where
  - modifier_order
  - override_in_extension
  - pattern_matching_keywords
  - prefer_self_in_static_references
  - private_action
  - sorted_first_last
  - toggle_bool
  - unneeded_parentheses_in_closure_argument
  - vertical_whitespace_closing_braces
  - yoda_condition

# Disabled rules
disabled_rules:
  - todo
  - trailing_comma

# Configuration
line_length:
  warning: 120
  error: 150
  ignores_comments: true

type_body_length:
  warning: 300
  error: 500

file_length:
  warning: 500
  error: 1000

identifier_name:
  min_length: 2
  excluded:
    - id
    - x
    - y
    - i
    - j

# Exclusions
excluded:
  - .build
  - Plugins
  - Tests/Fixtures

SwiftFormat Configuration (.swiftformat)

# Format options
--indent 4
--indentcase false
--trimwhitespace always
--stripunusedargs closure-only
--maxwidth 120
--wraparguments before-first
--wrapparameters before-first
--wrapcollections before-first

# Rules
--enable isEmpty
--enable sortImports
--enable wrapMultilineStatementBraces
--enable blankLineAfterImports

--disable redundantSelf
--disable trailingCommas

# Exclusions
--exclude .build,Plugins

Static Analysis Patterns

AST Visitor Pattern (SwiftSyntax)

import SwiftSyntax
import SwiftParser

final class DeclarationCollector: SyntaxVisitor {
    private(set) var declarations: [Declaration] = []
    
    override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind {
        let name = node.name.text
        let location = sourceLocation(of: node)
        declarations.append(Declaration(
            name: name,
            kind: .function,
            location: location
        ))
        return .visitChildren
    }
    
    override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
        let name = node.name.text
        declarations.append(Declaration(
            name: name,
            kind: .class,
            location: sourceLocation(of: node)
        ))
        return .visitChildren
    }
    
    private func sourceLocation(of node: some SyntaxProtocol) -> SourceLocation {
        let position = node.positionAfterSkippingLeadingTrivia
        return SourceLocation(
            offset: position.utf8Offset,
            line: 0,  // Calculate from source
            column: 0
        )
    }
}

// Usage
func analyze(source: String) -> [Declaration] {
    let sourceFile = Parser.parse(source: source)
    let collector = DeclarationCollector(viewMode: .sourceAccurate)
    collector.walk(sourceFile)
    return collector.declarations
}

Scope Tracking

final class ScopeTracker: SyntaxVisitor {
    private var scopeStack: [Scope] = []
    
    var currentScope: Scope? { scopeStack.last }
    
    override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
        scopeStack.append(Scope(name: node.name.text, kind: .class))
        return .visitChildren
    }
    
    override func visitPost(_ node: ClassDeclSyntax) {
        scopeStack.removeLast()
    }
    
    override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind {
        scopeStack.append(Scope(name: node.name.text, kind: .function))
        return .visitChildren
    }
    
    override func visitPost(_ node: FunctionDeclSyntax) {
        scopeStack.removeLast()
    }
}

Testing Strategies

In-Memory Testing

import XCTest
@testable import MyPackageCore

final class AnalyzerTests: XCTestCase {
    func testDetectsUnusedFunction() async throws {
        let source = """
        func unused() {}
        func used() { print("hello") }
        used()
        """
        
        let analyzer = Analyzer()
        let results = try await analyzer.analyze(source: source)
        
        XCTAssertEqual(results.count, 1)
        XCTAssertEqual(results[0].name, "unused")
    }
}

Fixture-Based Testing

final class FixtureTests: XCTestCase {
    func testComplexScenario() async throws {
        let fixtureURL = Bundle.module.url(
            forResource: "ComplexClass",
            withExtension: "swift",
            subdirectory: "Fixtures"
        )!
        
        let source = try String(contentsOf: fixtureURL)
        let results = try await analyzer.analyze(source: source)
        
        // Assertions
    }
}

Parallel Testing

swift test --parallel

Performance Optimization

Memory-Mapped I/O

func readFile(_ url: URL) throws -> String {
    let data = try Data(contentsOf: url, options: .mappedIfSafe)
    guard let string = String(data: data, encoding: .utf8) else {
        throw FileError.invalidEncoding
    }
    return string
}

Lazy Processing

struct LazyFileProcessor {
    let files: [URL]
    
    func process() -> some Sequence<Result> {
        files.lazy.compactMap { file in
            try? processFile(file)
        }
    }
}

Caching with Actor

actor AnalysisCache {
    private var cache: [URL: AnalysisResult] = [:]
    private var fileHashes: [URL: String] = [:]
    
    func get(_ url: URL) async throws -> AnalysisResult {
        let currentHash = try computeHash(url)
        
        if let cached = cache[url], fileHashes[url] == currentHash {
            return cached
        }
        
        let result = try await analyze(url)
        cache[url] = result
        fileHashes[url] = currentHash
        return result
    }
}

GitHub Actions CI/CD

Smart Tool Installation

Avoid redundant installation messages:

- name: Ensure Linting Tools
  run: |
    command -v swiftlint >/dev/null 2>&1 || brew install swiftlint
    command -v swiftformat >/dev/null 2>&1 || brew install swiftformat

CodeQL Security Analysis (v4)

Note: Use CodeQL Action v4 (v3 deprecated December 2026)

- uses: github/codeql-action/init@v4
  with:
    languages: swift
    build-mode: manual
- run: swift build -c release --arch arm64
- uses: github/codeql-action/analyze@v4

SPM Caching

- uses: actions/cache@v4
  with:
    path: |
      .build
      ~/Library/Developer/Xcode/DerivedData
    key: spm-${{ runner.os }}-${{ hashFiles('Package.resolved') }}

Quick Reference

Task Solution
Validate CLI arguments Use ExpressibleByArgument enums
Share code between modules Create a Core module with no external deps
Document API Use DocC catalog with <doc:Article> links
Distribute binaries Build universal with lipo
Ensure thread safety Use actor for shared state
Parse Swift code Use SwiftSyntax SyntaxVisitor
Run in CI Use command -v check before brew install
Fix SwiftLint CI cache errors Use --no-cache --force-exclude flags
Generate docs Use swift-docc-plugin
Security scanning Use CodeQL Action v4

Last updated: December 2025 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment