SwiftSyntax By Example
In this tutorial you’ll explore the SwiftSyntax API by writing a tool that formats and sorts the import statements in a Swift file.
You’ll start by using the Swift parser to parse code into a syntax tree. Then you’ll use syntax transformation APIs to analyze the structure of the document. Finally, you’ll write out fully-formatted Swift code as output.
Create a New Executable Package using SwiftSyntax
Create a new executable package that uses SwiftSyntax.
We’ll keep our formatter tool contained to the inputformatter.swift
file generated by the package. You will not need to add any new files during this tutorial.
Open Xcode and choose File > New > Package, name your package “importformatter”, and choose a place for it on disk.
Select the Package.swift file in the file navigator.
Package.swift
Package.step1.swift// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "importformatter", products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "importformatter", targets: ["importformatter"] ) ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "importformatter" ), .testTarget( name: "importformatterTests", dependencies: ["importformatter"] ), ] )
Change the
.library
entry forimportformatter
to a.executable
entry, and change the.target
entry to a.executableTarget
.The completed
Package.swift
file should look like this:Package.swift
Package.step2.swift// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "importformatter", products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .executable( name: "importformatter", targets: ["importformatter"] ) ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .executableTarget( name: "importformatter" ), .testTarget( name: "importformatterTests", dependencies: ["importformatter"] ), ] )
Configure Your Package
Configure your package and get started with a command line application.
Open the
Package.swift
file created in the previous section.Package.swift
Package.step2.swift// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "importformatter", products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .executable( name: "importformatter", targets: ["importformatter"] ) ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .executableTarget( name: "importformatter" ), .testTarget( name: "importformatterTests", dependencies: ["importformatter"] ), ] )
Add the
swift-syntax
package to the dependencies section of yourPackage.swift
file.Package.swift
Package.step3.swift// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "importformatter", products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .executable( name: "importformatter", targets: ["importformatter"] ) ], dependencies: [ // Dependencies declare other packages that this package depends on. .package(url: "https://github.com/swiftlang/swift-syntax.git", branch: "main") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .executableTarget( name: "importformatter" ), .testTarget( name: "importformatterTests", dependencies: ["importformatter"] ), ] )
Add the
SwiftSyntax
andSwiftParser
products as dependencies of theimportformatter
executable target.Package.swift
Package.step4.swift// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "importformatter", products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .executable( name: "importformatter", targets: ["importformatter"] ) ], dependencies: [ // Dependencies declare other packages that this package depends on. .package(url: "https://github.com/swiftlang/swift-syntax.git", branch: "main") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .executableTarget( name: "importformatter", dependencies: [ .product(name: "SwiftSyntax", package: "swift-syntax"), .product(name: "SwiftParser", package: "swift-syntax"), ] ), .testTarget( name: "importformatterTests", dependencies: ["importformatter"] ), ] )
Add a
macOS 10.15
platform constraint to theplatforms
section of yourPackage.swift
file.Package.swift
Package.step5.swift// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "importformatter", platforms: [ .macOS(.v10_15) ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .executable( name: "importformatter", targets: ["importformatter"] ) ], dependencies: [ // Dependencies declare other packages that this package depends on. .package(url: "https://github.com/swiftlang/swift-syntax.git", branch: "main") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .executableTarget( name: "importformatter", dependencies: [ .product(name: "SwiftSyntax", package: "swift-syntax"), .product(name: "SwiftParser", package: "swift-syntax"), ] ), .testTarget( name: "importformatterTests", dependencies: ["importformatter"] ), ] )
Getting Started with SwiftSyntax
You’ll use SwiftSyntax to fill out the functionality of the formatter. The tool sends its output to standard out by printing the syntax tree. You should run the formatter on one or more test files while you follow this tutorial and observe the resulting output.
In the Xcode file navigator, select the
importformatter.swift
.importformatter.swift
Formatter.step1.swiftimport Foundation @main struct ImportFormatter { static func main() { } }
Using the provided code, set up a bare-bones command line application in
importformatter.swift
.importformatter.swift
Formatter.step2.swiftimport Foundation @main struct ImportFormatter { static func main() { guard CommandLine.arguments.count == 2 else { print("Not enough arguments!") return } let filePath = CommandLine.arguments[1] guard FileManager.default.fileExists(atPath: filePath) else { print("File doesn't exist at path: \(filePath)") return } guard let file = try? String(contentsOfFile: filePath) else { print("File at path isn't readable: \(filePath)") return } let formattedFile = ImportFormatter().formatImports(in: file) print(formattedFile) } func formatImports(in file: String) -> SourceFileSyntax { /* Formatter here */ } }
Add an import of the
SwiftSyntax
andSwiftParser
libraries inimportformatter.swift
importformatter.swift
Formatter.step3.swiftimport Foundation import SwiftParser import SwiftSyntax @main struct ImportFormatter { static func main() { guard CommandLine.arguments.count == 2 else { print("Not enough arguments!") return } let filePath = CommandLine.arguments[1] guard FileManager.default.fileExists(atPath: filePath) else { print("File doesn't exist at path: \(filePath)") return } guard let file = try? String(contentsOfFile: filePath) else { print("File at path isn't readable: \(filePath)") return } let formattedFile = ImportFormatter().formatImports(in: file) print(formattedFile) } func formatImports(in file: String) -> SourceFileSyntax { /* Formatter here */ } }
Call
Parser.parse
to parse the input file as aSourceFileSyntax
value.At this point, your formatter tool is ready to run, even if it doesn’t actually format any code. Create a test
swift
file pass it to the formatter to make sure it gets printed to the console in Xcode. To configure the arguments to a command line application, enter the scheme editor with Product > Scheme > Edit Scheme. Under the ‘Arguments’ tab you can provide the full path to your test Swift file.importformatter.swift
Formatter.step4.swiftimport Foundation import SwiftParser import SwiftSyntax @main struct ImportFormatter { static func main() { guard CommandLine.arguments.count == 2 else { print("Not enough arguments!") return } let filePath = CommandLine.arguments[1] guard FileManager.default.fileExists(atPath: filePath) else { print("File doesn't exist at path: \(filePath)") return } guard let file = try? String(contentsOfFile: filePath) else { print("File at path isn't readable: \(filePath)") return } let formattedFile = ImportFormatter().formatImports(in: file) print(formattedFile) } func formatImports(in file: String) -> SourceFileSyntax { let sourceFile = Parser.parse(source: file) /* format source file */ return sourceFile } }
Next, write a helper function that classifies code block items containing imports. To help keep track of the classified items, define an
Item
enum that has two cases: one forimport
declaration items and one forother
items.Small helper functions and helper types like these help make your Swift code easier to read and reason about.
importformatter.swift
Formatter.step5.swiftimport Foundation import SwiftParser import SwiftSyntax @main struct ImportFormatter { static func main() { guard CommandLine.arguments.count == 2 else { print("Not enough arguments!") return } let filePath = CommandLine.arguments[1] guard FileManager.default.fileExists(atPath: filePath) else { print("File doesn't exist at path: \(filePath)") return } guard let file = try? String(contentsOfFile: filePath) else { print("File at path isn't readable: \(filePath)") return } let formattedFile = ImportFormatter().formatImports(in: file) print(formattedFile) } func formatImports(in file: String) -> SourceFileSyntax { let sourceFile = Parser.parse(source: file) var items = classifyItems(in: sourceFile) return sourceFile } enum Item { case `import`(ImportDeclSyntax, CodeBlockItemSyntax) case other(CodeBlockItemSyntax) } func classifyItems(in file: SourceFileSyntax) -> [Item] { /* Classify items here */ } }
To implement the item classifier, map over the
statements
in the source file syntax node.The map sends the items with
ImportDeclSyntax
nodes toItem.import
values, and all other items toItem.other
values.importformatter.swift
Formatter.step6.swiftimport Foundation import SwiftParser import SwiftSyntax @main struct ImportFormatter { static func main() { guard CommandLine.arguments.count == 2 else { print("Not enough arguments!") return } let filePath = CommandLine.arguments[1] guard FileManager.default.fileExists(atPath: filePath) else { print("File doesn't exist at path: \(filePath)") return } guard let file = try? String(contentsOfFile: filePath) else { print("File at path isn't readable: \(filePath)") return } let formattedFile = ImportFormatter().formatImports(in: file) print(formattedFile) } func formatImports(in file: String) -> SourceFileSyntax { let sourceFile = Parser.parse(source: file) var items = classifyItems(in: sourceFile) return sourceFile } enum Item { case `import`(ImportDeclSyntax, CodeBlockItemSyntax) case other(CodeBlockItemSyntax) } func classifyItems(in file: SourceFileSyntax) -> [Item] { file .statements .map { statement in if case .decl(let decl) = statement.item, let `import` = decl.as(ImportDeclSyntax.self) { return .import(`import`, statement) } else { return .other(statement) } } } }
With the classifier function written, it’s time to implement the formatter itself. First, make sure that all of the
import
s appear at the start of the source file. Swift provides a handy method to let us group a related set of statements together:partition(by:)
. Calling this method on the classifieditems
also returns the index that divides theimport
s from theother
statements, which will come in handy later.importformatter.swift
Formatter.step7.swiftimport Foundation import SwiftParser import SwiftSyntax @main struct ImportFormatter { static func main() { guard CommandLine.arguments.count == 2 else { print("Not enough arguments!") return } let filePath = CommandLine.arguments[1] guard FileManager.default.fileExists(atPath: filePath) else { print("File doesn't exist at path: \(filePath)") return } guard let file = try? String(contentsOfFile: filePath) else { print("File at path isn't readable: \(filePath)") return } let formattedFile = ImportFormatter().formatImports(in: file) print(formattedFile) } func formatImports(in file: String) -> SourceFileSyntax { let sourceFile = Parser.parse(source: file) var items = classifyItems(in: sourceFile) let pivotPoint = items.partition { item in switch item { case .import(_, _): return false case .other(_): return true } } return sourceFile } enum Item { case `import`(ImportDeclSyntax, CodeBlockItemSyntax) case other(CodeBlockItemSyntax) } func classifyItems(in file: SourceFileSyntax) -> [Item] { file .statements .map { statement in if case .decl(let decl) = statement.item, let `import` = decl.as(ImportDeclSyntax.self) { return .import(`import`, statement) } else { return .other(statement) } } } }
Next, sort the imports among each other by calling
sort(by:)
using the lexicographic comparison provided by Swift’sString
.Notice that we don’t need to sort the entire array of statements, just the imports. The call to
partition(by:)
gave us precisely the range of the statements collection we actually need to sort!importformatter.swift
Formatter.step8.swiftimport Foundation import SwiftParser import SwiftSyntax @main struct ImportFormatter { static func main() { guard CommandLine.arguments.count == 2 else { print("Not enough arguments!") return } let filePath = CommandLine.arguments[1] guard FileManager.default.fileExists(atPath: filePath) else { print("File doesn't exist at path: \(filePath)") return } guard let file = try? String(contentsOfFile: filePath) else { print("File at path isn't readable: \(filePath)") return } let formattedFile = ImportFormatter().formatImports(in: file) print(formattedFile) } func formatImports(in file: String) -> SourceFileSyntax { let sourceFile = Parser.parse(source: file) var items = classifyItems(in: sourceFile) let pivotPoint = items.partition { item in switch item { case .import(_, _): return false case .other(_): return true } } items[..<pivotPoint] .sort { lhs, rhs in guard case let .import(lhsImport, _) = lhs, case let .import(rhsImport, _) = rhs else { fatalError("Partition must only contain import items!") } return lhsImport.path.description < rhsImport.path.description } return sourceFile } enum Item { case `import`(ImportDeclSyntax, CodeBlockItemSyntax) case other(CodeBlockItemSyntax) } func classifyItems(in file: SourceFileSyntax) -> [Item] { file .statements .map { statement in if case .decl(let decl) = statement.item, let `import` = decl.as(ImportDeclSyntax.self) { return .import(`import`, statement) } else { return .other(statement) } } } }
Now that the array of items is sorted and everything is in the right position, modify the source file to actually use those statements. Call the
map
method to extract the statements from the array of items.importformatter.swift
Formatter.step9.swiftimport Foundation import SwiftParser import SwiftSyntax @main struct ImportFormatter { static func main() { guard CommandLine.arguments.count == 2 else { print("Not enough arguments!") return } let filePath = CommandLine.arguments[1] guard FileManager.default.fileExists(atPath: filePath) else { print("File doesn't exist at path: \(filePath)") return } guard let file = try? String(contentsOfFile: filePath) else { print("File at path isn't readable: \(filePath)") return } let formattedFile = ImportFormatter().formatImports(in: file) print(formattedFile) } func formatImports(in file: String) -> SourceFileSyntax { let sourceFile = Parser.parse(source: file) var items = classifyItems(in: sourceFile) let pivotPoint = items.partition { item in switch item { case .import(_, _): return false case .other(_): return true } } items[..<pivotPoint] .sort { lhs, rhs in guard case let .import(lhsImport, _) = lhs, case let .import(rhsImport, _) = rhs else { fatalError("Partition must only contain import items!") } return lhsImport.path.description < rhsImport.path.description } let formattedStatements = items.map { item in switch item { case .import(_, let statement): return statement case .other(let statement): return statement } } return sourceFile } enum Item { case `import`(ImportDeclSyntax, CodeBlockItemSyntax) case other(CodeBlockItemSyntax) } func classifyItems(in file: SourceFileSyntax) -> [Item] { file .statements .map { statement in if case .decl(let decl) = statement.item, let `import` = decl.as(ImportDeclSyntax.self) { return .import(`import`, statement) } else { return .other(statement) } } } }
Then use
with(\.statements, <#new statements#>)*
method to create a source file that contains the extracted array of statements.The syntax tree is immutable. The result of calling
with
is always a copy of a syntax tree node with the specified child element changed.importformatter.swift
Formatter.step10.swiftimport Foundation import SwiftParser import SwiftSyntax @main struct ImportFormatter { static func main() { guard CommandLine.arguments.count == 2 else { print("Not enough arguments!") return } let filePath = CommandLine.arguments[1] guard FileManager.default.fileExists(atPath: filePath) else { print("File doesn't exist at path: \(filePath)") return } guard let file = try? String(contentsOfFile: filePath) else { print("File at path isn't readable: \(filePath)") return } let formattedFile = ImportFormatter().formatImports(in: file) print(formattedFile) } func formatImports(in file: String) -> SourceFileSyntax { let sourceFile = Parser.parse(source: file) var items = classifyItems(in: sourceFile) let pivotPoint = items.partition { item in switch item { case .import(_, _): return false case .other(_): return true } } items[..<pivotPoint] .sort { lhs, rhs in guard case let .import(lhsImport, _) = lhs, case let .import(rhsImport, _) = rhs else { fatalError("Partition must only contain import items!") } return lhsImport.path.description < rhsImport.path.description } let formattedStatements = items.map { item in switch item { case .import(_, let statement): return statement case .other(let statement): return statement } } return sourceFile .with(\.statements, CodeBlockItemListSyntax(formattedStatements)) } enum Item { case `import`(ImportDeclSyntax, CodeBlockItemSyntax) case other(CodeBlockItemSyntax) } func classifyItems(in file: SourceFileSyntax) -> [Item] { file .statements .map { statement in if case .decl(let decl) = statement.item, let `import` = decl.as(ImportDeclSyntax.self) { return .import(`import`, statement) } else { return .other(statement) } } } }
Cleaning Up The Syntax Tree
Now that you have the core of the formatter done, you’ll learn how to manipulate whitespace to clean up its output.
Syntax trees created with the Swift parser and SwiftSyntax provide a property called source fidelity. This means that all whitespace, comments, and even invisible bytes in the file are represented in the tree. Collectively, we refer to these entities as trivia.
In this section, you’ll use methods provided by SwiftSyntax to manipulate the trivia attached to syntax nodes.
The formatter is nearly complete, but there’s still a pretty nasty set of bugs. Run the tool on the
importformatter.swift
file itself and observe its output carefully.The imports of
SwiftParser
andSwiftSyntax
are jammed together with no whitespace between them. The import ofFoundation
has an extra leading newline.importformatter.swift
Formatter.step10.swiftimport Foundation import SwiftParser import SwiftSyntax @main struct ImportFormatter { static func main() { guard CommandLine.arguments.count == 2 else { print("Not enough arguments!") return } let filePath = CommandLine.arguments[1] guard FileManager.default.fileExists(atPath: filePath) else { print("File doesn't exist at path: \(filePath)") return } guard let file = try? String(contentsOfFile: filePath) else { print("File at path isn't readable: \(filePath)") return } let formattedFile = ImportFormatter().formatImports(in: file) print(formattedFile) } func formatImports(in file: String) -> SourceFileSyntax { let sourceFile = Parser.parse(source: file) var items = classifyItems(in: sourceFile) let pivotPoint = items.partition { item in switch item { case .import(_, _): return false case .other(_): return true } } items[..<pivotPoint] .sort { lhs, rhs in guard case let .import(lhsImport, _) = lhs, case let .import(rhsImport, _) = rhs else { fatalError("Partition must only contain import items!") } return lhsImport.path.description < rhsImport.path.description } let formattedStatements = items.map { item in switch item { case .import(_, let statement): return statement case .other(let statement): return statement } } return sourceFile .with(\.statements, CodeBlockItemListSyntax(formattedStatements)) } enum Item { case `import`(ImportDeclSyntax, CodeBlockItemSyntax) case other(CodeBlockItemSyntax) } func classifyItems(in file: SourceFileSyntax) -> [Item] { file .statements .map { statement in if case .decl(let decl) = statement.item, let `import` = decl.as(ImportDeclSyntax.self) { return .import(`import`, statement) } else { return .other(statement) } } } }
Normalize the whitespace of all the imports by calling the trivia manipulation functions
with(\.leadingTrivia, _:)
andwith(\.trailingTrivia, _:)
on import items.SwiftSyntax does not automatically format whitespace and trivia when items are moved around. SwiftSyntax provides a convenient way of manipulating whitespace by calling the
with(\.leadingTrivia, _:)
andwith(\.trailingTrivia, _:)
methods. By normalizing all of the whitespace to a single leading newline we can fix bug #1. And by removing all trailing whitespace we can fix bug #2.importformatter.swift
Formatter.step11.swiftimport Foundation import SwiftParser import SwiftSyntax @main struct ImportFormatter { static func main() { guard CommandLine.arguments.count == 2 else { print("Not enough arguments!") return } let filePath = CommandLine.arguments[1] guard FileManager.default.fileExists(atPath: filePath) else { print("File doesn't exist at path: \(filePath)") return } guard let file = try? String(contentsOfFile: filePath) else { print("File at path isn't readable: \(filePath)") return } let formattedFile = ImportFormatter().formatImports(in: file) print(formattedFile) } func formatImports(in file: String) -> SourceFileSyntax { let sourceFile = Parser.parse(source: file) var items = classifyItems(in: sourceFile) let pivotPoint = items.partition { item in switch item { case .import(_, _): return false case .other(_): return true } } items[..<pivotPoint] .sort { lhs, rhs in guard case let .import(lhsImport, _) = lhs, case let .import(rhsImport, _) = rhs else { fatalError("Partition must only contain import items!") } return lhsImport.path.description < rhsImport.path.description } let formattedStatements = items.enumerated().map { (offset, item) in switch item { case .import(_, let statement): return statement .with(\.leadingTrivia, offset == 0 ? [] : .newline) .with(\.trailingTrivia, []) case .other(let statement): return statement } } return sourceFile .with(\.statements, CodeBlockItemListSyntax(formattedStatements)) } enum Item { case `import`(ImportDeclSyntax, CodeBlockItemSyntax) case other(CodeBlockItemSyntax) } func classifyItems(in file: SourceFileSyntax) -> [Item] { file .statements .map { statement in if case .decl(let decl) = statement.item, let `import` = decl.as(ImportDeclSyntax.self) { return .import(`import`, statement) } else { return .other(statement) } } } }
Check your progress. Try re-running the formatter on the
importformatter.swift
file again. This time, notice that the import statements are not just sorted, but have whitespace corrected and normalized.There’s still some work to do here, though. Because we replaced all of the trivia, we’ve also removed any comments attached to the imports. As an added challenge, try to edit the trivia to preserve comments and only change whitespace.
importformatter.swift
Formatter.step11.swiftimport Foundation import SwiftParser import SwiftSyntax @main struct ImportFormatter { static func main() { guard CommandLine.arguments.count == 2 else { print("Not enough arguments!") return } let filePath = CommandLine.arguments[1] guard FileManager.default.fileExists(atPath: filePath) else { print("File doesn't exist at path: \(filePath)") return } guard let file = try? String(contentsOfFile: filePath) else { print("File at path isn't readable: \(filePath)") return } let formattedFile = ImportFormatter().formatImports(in: file) print(formattedFile) } func formatImports(in file: String) -> SourceFileSyntax { let sourceFile = Parser.parse(source: file) var items = classifyItems(in: sourceFile) let pivotPoint = items.partition { item in switch item { case .import(_, _): return false case .other(_): return true } } items[..<pivotPoint] .sort { lhs, rhs in guard case let .import(lhsImport, _) = lhs, case let .import(rhsImport, _) = rhs else { fatalError("Partition must only contain import items!") } return lhsImport.path.description < rhsImport.path.description } let formattedStatements = items.enumerated().map { (offset, item) in switch item { case .import(_, let statement): return statement .with(\.leadingTrivia, offset == 0 ? [] : .newline) .with(\.trailingTrivia, []) case .other(let statement): return statement } } return sourceFile .with(\.statements, CodeBlockItemListSyntax(formattedStatements)) } enum Item { case `import`(ImportDeclSyntax, CodeBlockItemSyntax) case other(CodeBlockItemSyntax) } func classifyItems(in file: SourceFileSyntax) -> [Item] { file .statements .map { statement in if case .decl(let decl) = statement.item, let `import` = decl.as(ImportDeclSyntax.self) { return .import(`import`, statement) } else { return .other(statement) } } } }