Sharing code in multi-target swift package

17 Aug 2022

Tags: development, swift

This is a quick post to write up an answer to a problem I hit yesterday with Swift Package Manager projects where I found a bunch of people asking the same question and no posted answer. This post will hopefully help someone else, or at least me when I hit this again in a year and have forgotten the answer :)

I wanted to create a set of related command line tools in Swift that’d run on both macOS and Linux, so I set up a project using Swift Pacakge Manager rather than Xcode. I suspect most people think of SPM as being for libraries, but you can just was easily create executable projects if you invoke it correctly:

$ mkdir myproject
$ cd myproject
$ swift package init --type executable

This will generate a package manifest that then has an executableTarget rather than just a regular target. But the fun thing is you can add multiple such targets. So in my case I wanted to write a couple of related commands than formed a data processing pipeline, so I had a Packages.swift that looked a little like this:

import PackageDescription
let package = Package(
	name: "myproject",
	dependencies: [],
	targets: [
		.executableTarget(
			name: "stage1",
			dependencies: []
		)
		.executableTarget(
			name: "stage2",
			dependencies: []
		)
	]
)

And on disk I have a file layout like:

myproject/
	Package.swift
	Sources/
		stage1/
			main.swift
		stage2/
			main.swift

The issue though was I wanted to pass data between stage1 and stage2 in JSON, so I made a swift file that contained the structs that I’d use to define my JSON data. But the problem where should I put my shared data? By default Swift will only build each command with swift files within their respective folders. So to do that I’d need to do:

myproject/
	Package.swift
	Sources/
		stage1/
			main.swift
			shared.swift
		stage2/
			main.swift
			shared.swift

Which is clearly poor. I could use a symlink, but that feels fragile when you start having version control involved and moving between machines etc. The inference I took from the docs was that I should be making a seperate package for the common code, but that also feels very heavyweight for a simple project like I had in mind.

Really what I wanted to do was have a shared code folder like this:

myproject/
	Package.swift
	Sources/
		shared/
			shared.swift
		stage1/
			main.swift
		stage2/
			main.swift

But obviously swift can’t then see it: if it could, then it’d also see multiple main.swift files and get confused. You can try and be naughty and hard code the sources in the package manifest with a “../shared/shared.swift”, which the documentation explicitly tells you not to do, and whilst the compiler doesn’t generate errors around this, it doesn’t actually find the code, so no joy.

In the end the correct solution was to add yet another target: I made shared a library! So with the files as above, I updated my manifest to read:

import PackageDescription
let package = Package(
	name: "myproject",
	dependencies: [],
	targets: [
		.target(
			name: "shared",
			dependancies: []
		),
		.executableTarget(
			name: "stage1",
			dependencies: ["shared"]
		)
		.executableTarget(
			name: "stage2",
			dependencies: ["shared"]
		)
	]
)

Now I can just do “import shared” in my two main files, and everything is fine!

Well, kinda. The downside to this is if all you’re trying to do is share a few pure structs (that is, structs that are just a collection of values and don’t have methods to define behaviour), you loose the convenience of the default constructor, as that that constructor isn’t public. So I’ve just doubled the amount of code as I have to go from:

struct mydata {
	value1: String
	value2: Int
}

to:

public struct mydata {
	public value1: String
	public value2: Int

	public init(value1: String, value2: Int) {
		self.value1
		self.value2
	}
}

Not much for a small struct like that, but if you’re trying to share a more complex data structure it gets a bit tedious quickly.

Still, it is at least possible to build up a tool collection in this fashion using SPM, despite the wealth of unanswered or incorrectly answered forum questions to the contrary.

Update

I will say, having done more work on my little project, I have found another benefit to this arrangement: it’s easier to write tests, as the unit test runner doesn’t seem to like writing functional tests on the command line tool code - some tests will run but others will crash with a segfault that I’ve not yet tracked down. But if you have tests on your shared library everything is fine. So in the end I’ve migrated most of the logic into the shared code base to make it testable, and the command line executables just deal with the run-time I/O of things, and the logic is all now in shared.

Digital Flapjack Ltd, UK Company 06788544