23 Feb 2025
TL;DR
Try creating a cli
executable in your project that exposes common project tasks that are written in the project’s core language.
This allows better contribution and less single points of failure with pockets of knowledge in the team.
Scene setting
Over time projects accumulate helper scripts to perform various admin tasks.
I’ve historically tried to avoid bash
as much as possible for these scripts because the projects I work on often have teams of people unfamiliar with bash
or its idiosyncrasies.
With this in mind I’ve then gravitated towards Ruby
because I’ve always loved the language and it’s a safer choice in my mind.
Unfortunately I’ve been kidding myself because as much as I love Ruby
it still has the same issue as bash with people not knowing it and also it’s a right pain to make sure people’s environment are set up.
The next logical step is to just use the main project’s language for building up tasks.
This is potentially easier said than done but I’ve seen success with doing it.
As a mobile developer this means using Swift
with SPM
to build out tasks on the iOS side and Kotlin
with gradle
on the backend/mulitplatform parts.
The good
In taking this approach I’ve removed myself from being the single point of failure on maintaining stuff.
This not only means that I don’t have to be on hand to debug things but also opens the door for easier contribution/reuse.
For example with Swift
being the langauge used to write an admin script other people have contributed various tasks with the obvious plus being that the whole team can much more easily adopt and understand what is being done without trying to understand cryptic personal scripts.
Using languages like Swift
/Kotlin
encourages me to write more reusable code than if I was just slinging bash
around.
For example I’d write a Github
client that can be reused rather than being lazy and copy/pasta’ing curl
invocations around with duplicated configuration.
You have the full power of available libraries like type safe serialisation with Codable
or by pulling in something like kotlinx.serialization
.
I can’t even count how many times I’ve written dodgy JSON interpolation in scripts when really I should have just not been lazy and used the right tools for the job.
Debugging is a super power for these scripts even though I might end up cave man debugging (print
) I love having the option to use a debugger and inspect all the things or try changing state on the fly to see what would happen.
Types, types, types…
I love types and they are really handy for helping me write safe code.
The bad
Both Swift
and Kotlin
just aren’t that great as scripting languages even though I really want them to be.
This may be a personal lack of competence but when I’m writing scripts I’m looking for super fast feedback, which means I’ll often start just curl
ing things on the command line or opening TextMate
, setting it to bash and hitting ⌘+R
.
With both of these I’m running code straight away with very little ceremony, which I simply can’t reliably do for Swift
or Kotlin
as both pretty much require that I use an IDE to help with types and missed keywords (try
, await
, suspend
…).
This may sound contradictory to Types, types, types...
but at different points in the development process I value different things.
Often when I am just trying things out I’m not very professional and just want to throw code around to see what works before I put on my big developer pants and do the job properly.
Another weakness is forking.
In bash
or Ruby
I can just slap backticks around a command to have it run in a subshell and then collect the result.
It’s just not that simple in Swift
/Kotlin
even when pulling in libraries, which I do.
Approach
I’m not 100% sold on the exact naming/layout but as a reference this is what I set up.
We have a shim at the root of the project called cli
, this file’s job is to essentially cd
into the project that has the tasks and call swift build
followed by running the built exectuable.
It’s a little bit of redirection but it’s certainly easier than expecting people to remember the calling convention.
My other thinking is that if we come to some standarisation that in our projects you just call ./cli
to be presented with all the various admin tasks then it’s just one thing to learn.
With this in place we currently use swift-argument-parser
to build a cli that has various subcommands as an example for some inspiration here’s some top level tasks that we’ve built out
OVERVIEW: A utility for working with the ios repo.
USAGE: cli <subcommand>
OPTIONS:
-h, --help Show help information.
SUBCOMMANDS:
ci Commands the CI pipeline uses
code-gen Regenerate generated code for the app.
collect-debug-info Print information useful for getting help with debugging.
doctor Help diagnose environment set up issues.
firewall Add/Remove firewall rules for simulator
kmp-doctor Update local repository to add KMP files into Xcode
set-marketing-value Create a branch with a commit that updates to the passed version number.
sim Utilities for working with simulators.
On the Kotlin
side we’ve been using clikt
to perform the role of swift-argument-parser
but set up is very similar.
Misfires
I spent far too long trying to use cute tricks like #!/usr/bin/env kotlin
with kts
files to get the “scripting” feel with the language of choice.
I personally found this a complete train wreck as I had to pull in loads of dependencies using @file:DependsOn
and then very quickly hit the fact that I can’t write Kotlin
without an IDE.
For some reason IntelliJ was giving me no help with limited syntax highlighting and no suggestions.
To actually get anything working I had to create a project, import dependencies in the normal way and then once I had code that worked and had all the right imports etc I could copy/pasta it over.
At which point I sat scratching my head wondering why my brain hadn’t stopped me doing such a ridiculous thing - e.g. if I only committed the kts
file I’d be committing the lossy version of my work that is hard to debug or work with.
04 Jul 2024
I work in a team with many colleagues where we are responsible for several code bases.
Often if someone has an issue with running a project you end up either assuming you’ll have the same environment and forget to ask or spend time probing for details about the person’s system.
I think this is an ideal case for putting a small script in your project that will collect information that will be generally useful for helping debug project level issues.
For example on an iOS project I might have a script like this as a starting point
bin/collect-debug-info
#!/bin/bash
cat << EOF
OS: $(sw_vers --productName) $(sw_vers --productVersion) ($(sw_vers --buildVersion))
Git: $(git rev-parse --abbrev-ref HEAD) ($(git rev-parse HEAD))
Xcode: $(xcode-select -p)
Simulators:
$(xcrun simctl list devices booted)
Mise $(mise --version):
$(mise list --current)
EOF
An example output might be:
OS: macOS 14.3.1 (23D60)
Git: main (aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa)
Xcode: /Applications/Xcode-15.4.0.app/Contents/Developer
Simulators:
== Devices ==
-- iOS 16.4 --
-- iOS 17.0 --
-- iOS 17.0 --
-- iOS 17.2 --
-- iOS 17.4 --
-- iOS 17.5 --
iPhone 11 (AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAAE) (Booted)
Mise 2024.7.0 macos-arm64 (e518900 2024-07-03):
jq 1.7.1 ~/src/ios/my-proj/.mise.toml latest
ruby 3.3.0 ~/src/ios/my-proj/.mise.toml 3.3.0
swiftformat 0.53.9 ~/src/ios/my-proj/.mise.toml 0.53.9
swiftlint 0.55.0 ~/src/ios/my-proj/.mise.toml 0.55.0
tuist 4.17.0 ~/src/ios/my-proj/.mise.toml 4.17.0
xcodes 1.4.1 ~/src/ios/my-proj/.mise.toml 1.4.1
Now when someone asks for help and I suspect there might be environment issues I can just ask for the output of bin/collect-debug-info
and we’ll be up to speed debugging in no time.
This is the kind of script you can build up over time and add all kinds of useful info as and when you decide it would be useful to collect.
28 May 2024
I had a case recently where I wanted to migrate an Objective-C class to Swift but as it was a large class.
I wanted to go one method at a time to allow easier reviewing and to keep my sanity, whilst having each step still pass all unit tests.
I quickly hit issues where it seemed like I would have to bite the bullet and just do it as a single large commit.
Helpfully I saw a proposal to allow you to provide Objective-C implementations in Swift, which lead me to finding the _
version of the feature spelt @_objcImplementation
that is perfect for my quick migration until the full implementation lands.
Starting point
Let’s say I have the following simple class that I want to migrate one function at a time
MyObject.h
@interface MyObject: NSObject
- (void)doSomething1;
- (void)doSomething2;
@end
MyObject.m
@interface MyObject ()
@property (nonatomic, copy) NSString *title;
@end
@implementation MyObject
- (void)doSomething1 { ... }
- (void)doSomething2 { ... }
@end
The above is a class with two public methods and a “private” property declared in an anonymous category.
One step migration
If I wanted to migrate this in one go I can delete the .m
file and create a Swift file like this
MyObject.swift
@_objcImplementation MyObject { }
At this point the compiler will complain about the missing implementations
Extension for main class interface should provide implementation for instance method ‘doSomething1()’; this will become an error before ‘@_objcImplementation’ is stabilized
Extension for main class interface should provide implementation for instance method ‘doSomething2()’; this will become an error before ‘@_objcImplementation’ is stabilized
To make the compiler happy I need to provide all the implementations like so:
MyObject.swift
@_objcImplementation MyObject {
func doSomething1() { ... }
func doSomething2() { ... }
}
This might be fine for small classes but my goal was to be able to break the task down into small chunks whilst keeping everything compiling and tests passing.
Create named categories
To do this in a more controlled way the best thing to do is to split the @interface
into named categories and then specify the category name in the annotation.
For example I called my category SwiftMigration
MyObject.h
@interface MyObject: NSObject
- (void)doSomething2;
@end
@interface MyObject (SwiftMigration)
- (void)doSomething1;
@end
The corresponding Swift file that targets the category now only needs to implement the one method and would look like this:
MyObject.swift
@_objcImplementation(SwiftMigration) extension MyObject {
func doSomething1() { ... }
}
With this approach I can go one method at a time and it doesn’t feel like such a big risk doing the port.
Properties
In the example above I have a property declared in an anonymous category which essentially makes it private to my class implementation.
Normally you cannot declare new storage in extension
s but with @_objcImplementation
you are allowed to declare storage on the top implementation (the unnamed one), which would look like this:
MyObject.swift
@_objcImplementation extension MyObject {
private var title: String?
}
Final clean up
Whether migrating in one go or piece by piece it’s then worth asking if the @_objcImplementation
is required at all or if you can delete the header file and make it a pure Swift class.
There are cases where you might need to continue to use the new capabilities like if you still have code in Objective-C that subclasses your class.
General migration strategies
There are other ways of avoiding rewriting large amounts of code whilst still taking advantage of Swift.
I use these techniques to help avoid adding any new Objective-C.
Extensions
Swift extensions are a great way for adding new Swift code to legacy Objective-C code.
In the simple case where all call sites will only be Swift based you can create an extension and don’t annotate it as @objc
MyObject.swift
extension MyObject {
func doSomethingNew() { ... }
}
I will even prefer doing this over writing too much new Objective-C in the a class.
For example I might just annotate my doSomethingNew
function as @objc
and then call it on self
.
This isn’t perfect for encapsulation but I don’t generally write frameworks and I’m happy to ignore the purity for the added safety.
MyObject.m
@implementation MyObject
- (void)doSomething
{
[self doSomethingNew];
}
@end
Shims
In cases where I know the interop between Swift and Objective-C should be fairly short lived I’ll often create small shims.
The aim is to write the Swift code in the most natural style and then just write ugly bridge code in the shim with the knowledge that in future I can delete the shim and won’t need to reevaluate the interface of the underlying class.
For example I might have
class MyObject {
func doSomething(completion: (Result<String, Error>) -> Error) { ... }
}
With the above I can’t annotate the method with @objc
because Objective-C can’t represent the Result
type.
Instead of making this less Swifty I’d write a shim like this
class MyObject {
@objc
func doSomething(completion: (Bool, String?, Error?) -> Error) {
doSomething { result in
switch result {
case let .success(string):
completion(true, string, nil)
case let .failure(error):
completion(false, nil, error)
}
}
}
func doSomething(completion: (Result<String, Error>) -> Error) { ... }
}
Wrap up
Although it may not always make sense it’s amazing how many bugs you find when you look at porting Objective-C to Swift.
There’s the obvious errors that language features help you avoid writing and then there is just being forced to look at old code with fresh eyes and new patterns.