22 KiB
Brisk is a proof of concept scripting library for Swift developers. It keeps all the features we like about Swift, but provides wrappers around common functionality to make them more convenient for local scripting.
Do you see that 💥 right there next to the logo? That’s there for a reason: Brisk bypasses some of Swift’s built-in safety features to make it behave more like Python or Ruby, which means it’s awesome for quick scripts but a really, really bad idea to use in shipping apps.
This means you get:
- All of Swift’s type safety
- All of Swift’s functionality (protocols, extensions, etc)
- All of Swift’s performance
But:
- Many calls that use
try
are assumed to work – if they don’t, your code will print a message and either continue or halt depending on your setting. - You get many helper functions that make common scripting functionality easier: reading and writing files, parsing JSON and XML, string manipulation, regular expressions, and more.
- Network fetches are synchronous.
- Strings can be indexed using integers, and you can add, subtract, multiply, and divide
Int
,Double
, andCGFloat
freely. SosomeStr[3]
andsomeInt + someDouble
works as in scripting languages. (Again, please don’t use this in production code.) - We assume many sensible defaults: you want to write strings with UTF-8, you want to create directories with intermediates,
trim()
should remove whitespace unless asked otherwise, and so on.
We don’t replace any of Swift’s default functionality, which means if you want to mix Brisk’s scripting wrappers with the full Foundation APIs (or Apple’s other frameworks), you can.
So, it’s called Brisk: it’s fast like Swift, but with that little element of risk 🙂
Installation
Run these two commands:
git clone https://github.com/twostraws/Brisk
cd Brisk
make install
Brisk installs a template full of its helper functions in ~/.brisk
, plus a simple helper script in /usr/local/bin
.
Usage:
brisk myscriptname
That will create a new directory called myscriptname
, copy in all the helper functions, then open it in Xcode ready for you to edit. Using Xcode means you get full code completion, and can run your script by pressing Cmd+R like usual.
Using swift run
from that directory, the script will run from the command line; if you use swift build
you'll get a finished binary you can put anywhere.
Warning: The brisk
command is easily the most experimental part of this whole package, so please let me know how you get on with it. Ideally it should create open Xcode straight to an editing window saying print("Hello, Brisk!")
, but let me know if you get something else.
Examples
This creates a directory, changes into it, copies in an example JSON file, parses it into a string array, then saves the number of items in a new file called output.txt:
mkdir("example")
chdir("example")
fileCopy("~/example.json", to: ".")
let names = decode(file: "example.json", as: [String].self)
let output = "Result \(names.count)"
output.write(to: "output.txt")
If you were writing this using the regular Foundation APIs, your code might look something like this:
let fm = FileManager.default
try fm.createDirectory(atPath: "example", withIntermediateDirectories: true)
fm.changeCurrentDirectoryPath("example")
try fm.copyItem(atPath: NSHomeDirectory() + "/example.json", toPath: "example")
let input = try String(contentsOfFile: "example.json")
let data = Data(input.utf8)
let names = try JSONDecoder().decode([String].self, from: data)
let output = "Result \(names.count)"
try output.write(toFile: "output.txt", atomically: true, encoding: .utf8)
The Foundation code has lots of throwing functions, which is why we need to repeat the use of try
. This is really important when shipping production software because it forces us to handle errors gracefully, but in simple scripts where you know the structure of your code, it gets in the way.
This example finds all .txt files in a directory and its subdirectories, counting how many lines there are in total:
var totalLines = 0
for file in scandir("~/Input", recursively: true) {
guard file.hasSuffix(".txt") else { continue }
let contents = String(file: "~/Input"/file) ?? ""
totalLines += contents.lines.count
}
print("Counted \(totalLines) lines")
Or using recurse()
:
var totalLines = 0
recurse("~/Input", extensions: ".txt") { file in
let contents = String(file: "~/Input"/file) ?? ""
totalLines += contents.lines.count
}
print("Counted \(totalLines) lines")
And here’s the same thing using the Foundation APIs:
let enumerator = FileManager.default.enumerator(atPath: NSHomeDirectory() + "/Input")
let files = enumerator?.allObjects as! [String]
var totalLines = 0
for file in files {
guard file.hasSuffix(".txt") else { continue }
let contents = try! String(contentsOfFile: NSHomeDirectory() + "/Input/\(file)")
totalLines += contents.components(separatedBy: .newlines).count
}
print("Counted \(totalLines) lines")
Here are some more examples – I’m not going to keep on showing you the Foundation equivalent, because you can imagine it for yourself.
This fetches the contents of Swift.org and checks whether it was changed since the script was last run:
let html = String(url: "https://www.swift.org")
let newHash = html.sha256()
let oldHash = String(file: "oldHash")
newHash.write(to: "oldHash")
if newHash != oldHash {
print("Site changed!")
}
This creates an array of names, removes any duplicates, then writes the result out to a file as JSON:
let names = ["Ron", "Harry", "Ron", "Hermione", "Ron"]
let json = names.unique().jsonData()
json.write(to: "names.txt")
This checks whether a string matches a regular expression:
let example = "Hacking with Swift is a great site."
if example.matches(regex: "(great|awesome) site") {
print("Trufax")
}
Loop through all files in a directory recursively, printing the name of each file and its string contents:
recurse("~/Input") { file in
let text = String(file: "~/Input"/file) ?? ""
print("\(file): \(text)")
}
Print whether a directory contains any zip files:
let contents = scandir("~/Input")
let hasZips = contents.any { $0.hasSuffix(".zip") }
print(hasZips)
This loads Apple’s latest newsroom RSS and prints out the titles of all the stories:
let data = Data(url: "https://apple.com/newsroom/rss-feed.rss")
if let node = parseXML(data) {
let titles = node.getElementsByTagName("title")
for title in titles {
print(title.data)
}
}
Wait, but… why?
I was working on a general purpose scripting library for Swift, following fairly standard Swift conventions – you created a struct to represent the file you wanted to work with, for example.
And it worked – you could write scripts in Swift that were a little less cumbersome than Foundation. But it still wasn’t nice: you could achieve results, but it still felt like Python, Ruby, or any number of alternatives were better choices, and I was choosing Swift just because it was Swift.
So, Brisk is a pragmatic selection of wrappers around Foundation APIs, letting us get quick results for common operations, but still draw on the full power of the language and Apple’s frameworks. The result is a set of function calls, initializers, and extensions that make common things trivial, while allowing you to benefit from Swift’s power features and “gracefully upgrade” to the full fat Foundation APIs whenever you need.
Naming conventions
This code has gone through so many iterations over time, because it’s fundamentally built on functions I’ve been using locally. However, as I worked towards an actual proof of concept I had to try to bring things together a cohesive way, which meant figuring out How to Name Things.
- When using long-time standard things from POSIX or C, those function names were preserved. So,
mkdir()
,chdir()
,getcwd()
, all exist. - Where equivalent functions existed in other popular languages, they were imported:
isdir()
,scandir()
,recurse()
,getpid()
,basename()
, etc. - Where functionality made for natural extensions of common Swift types –
String
,Comparable
,Date
, etc – extensions were always preferred.
The only really problematic names were things for common file operations, such as checking whether a file exists or reading the contents of a file. Originally I used short names such as exists("someFile.txt")
and copy("someFile", to: "dir")
, which made for concise and expressive code. However, as soon as you made a variable called copy
– which is easily done! – you lose visibility to the function
I then moved to using File.copy()
, File.exists()
and more, giving the functions a clear namespace. That works great for avoiding name collisions, and also helps with discoverability, but became more cumbersome to read and write. So, after trying them both for a while I found that the current versions worked best: fileDelete()
, and so on.
I’d be more than happy to continue exploring alternatives!
Reference
This needs way more documentation, but hopefully this is enough to get you started.
Extensions on Array
Removes all instances of an element from an array:
func remove(_: Element)
Extensions on Comparable
Clamps any comparable value between a low and a high value, inclusive:
func Comparable.clamp(low: Self, high: Self) -> Self
Extensions on Data
Calculates the hash value of this Data
instance:
func Data.md5() -> String
func Data.sha1() -> String
func Data.sha256() -> String
Converts the Data
instance to base 64 representation:
func Data.base64() -> String
Writes the Data
instance to a file path; returns true on success or false otherwise:
func write(to file: String) -> Bool
Creates a Data
instance by downloading from a URL or by reading a local file:
Data(url: String)
Data?(file: String)
Extensions on Date
Reads a Date
instance as an Unix epoch time integer:
func unixTime() -> Int
Formats a Date
as a string:
func string(using format: String) -> String
Decoding
Decodes a string to a specific Decodable
type, optionally providing strategies for decoding keys and dates:
func decode<T: Decodable>(string input: String, as type: T.Type, keys: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, dates: JSONDecoder.DateDecodingStrategy = .deferredToDate) -> T
The same as above, except now loading from a local file:
func decode<T: Decodable>(file: String, as type: T.Type, keys: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, dates: JSONDecoder.DateDecodingStrategy = .deferredToDate) -> T
Creates a Decodable
instance by fetching data a URL:
Decodable.init(url: String, keys: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, dates: JSONDecoder.DateDecodingStrategy = .deferredToDate)
Directories
The user’s home directory:
Directory.homeDir: String
Makes a directory:
@discardableResult func mkdir(_ directory: String, withIntermediates: Bool = true) -> Bool
Removes a directory:
@discardableResult func rmdir(_ file: String) -> Bool
func getcwd() -> String
Returns true if a file path represents a directory, or false otherwise:
func isdir(_ name: String) -> Bool
Changes the current working directory:
func chdir(_ newDirectory: String) -> Bool
Retrieves all files in a directory, either including all subdirectories or not:
func scandir(_ directory: String, recursively: Bool = false) -> [String]
Runs through all files in a directory, including subdirectories, and runs a closure for each file that matches an extension list:
func recurse(_ directory: String, extensions: String..., action: (String) throws -> Void) rethrows
Same as above, except now you can pass in a custom predicate:
func recurse(_ directory: String, predicate: (String) -> Bool, action: (String) throws -> Void) rethrows
Extensions on Encodable
Converts any Encodable
type to some JSON Data
, optionally providing strategies for encoding keys and dates:
func Encodable.jsonData(keys: JSONEncoder.KeyEncodingStrategy = .useDefaultKeys, dates: JSONEncoder.DateEncodingStrategy = .deferredToDate) -> Data
Same as above, except converts it a JSON String
:
func Decodable.jsonString(keys: JSONEncoder.KeyEncodingStrategy = .useDefaultKeys, dates: JSONEncoder.DateEncodingStrategy = .deferredToDate) -> String
Files
Creates a file, optionally providing initial contents. Returns true on success or false otherwise:
@discardableResult func fileCreate(_ file: String, contents: Data? = nil) -> Bool
Removes a file at a path; returns true on success or false otherwise:
@discardableResult func fileDelete(_ file: String) -> Bool
Returns true if a file exists:
func fileExists(_ name: String) -> Bool
Returns all properties for a file:
func fileProperties(_ file: String) -> [FileAttributeKey: Any]
Returns the size of a file:
func fileSize(_ file: String) -> UInt64
Returns the date a file was created or modified:
func fileCreation(_ file: String) -> Date
func fileModified(_ file: String) -> Date
Returns a temporary filename:
func tempFile() -> String
Returns the base name of a file – the filename itself, excluding any directories:
func basename(of file: String) -> String
Copies a file from one place to another:
@discardableResult func fileCopy(_ from: String, to: String) -> Bool
Numeric operators
A series of operator overloads that let you add, subtract, multiply, and divide across integers, floats, and doubles:
func +<I: BinaryInteger, F: BinaryFloatingPoint>(lhs: I, rhs: F) -> F
func +<I: BinaryInteger, F: BinaryFloatingPoint>(lhs: F, rhs: I) -> F
func -<I: BinaryInteger, F: BinaryFloatingPoint>(lhs: I, rhs: F) -> F
func -<I: BinaryInteger, F: BinaryFloatingPoint>(lhs: F, rhs: I) -> F
func *<I: BinaryInteger, F: BinaryFloatingPoint>(lhs: I, rhs: F) -> F
func *<I: BinaryInteger, F: BinaryFloatingPoint>(lhs: F, rhs: I) -> F
func /<I: BinaryInteger, F: BinaryFloatingPoint>(lhs: I, rhs: F) -> F
func /<I: BinaryInteger, F: BinaryFloatingPoint>(lhs: F, rhs: I) -> F
Processes
Returns the current process ID:
func getpid() -> Int
Returns the host name:
func getHostName() -> String
Returns the username of the logged in user:
func getUserName() -> String
Gets or sets environment variables:
func getenv(_ key: String) -> String
func setenv(_ key: String, _ value: String)
Extensions on Sequence
Returns any sequence, with duplicates removed. Element must conform to Hashable
:
func Sequence.unique() -> [Element]
Returns all the indexes where an element exists in a sequence. Element must conform to Equatable
:
func Sequence.indexes(of searchItem: Element) -> [Int]
Returns true if any or none of the items in a sequence match a predicate:
func any(match predicate: (Element) throws -> Bool) rethrows -> Bool
func none(match predicate: (Element) throws -> Bool) rethrows -> Bool
Returns several random numbers from a sequence, up to the number requested:
Sequence.random(_ num: Int) -> [Element]
Extensions on String
The string as an array of lines:
var lines: [String]
An operator that lets us join strings together into a path:
static func / (lhs: String, rhs: String) -> String
Calculates the hash value of this String
instance:
func md5() -> String
func sha1() -> String
func sha256() -> String
Converts the String
instance to base 64 representation:
func base64() -> String
Writes a string to a file:
@discardableResult func write(to file: String) -> Bool
Replaces all instances of one string with another in the source String
:
func replacing(_ search: String, with replacement: String) -> String
mutating func String.replace(_ search: String, with replacement: String)
Replaces count
instances of one string with another in the source String
:
func replacing(_ search: String, with replacement: String, count maxReplacements: Int) -> String
mutating func String.replace(_ search: String, with replacement: String, count maxReplacements: Int)
Trims characters from a string, whitespace by default:
mutating func trim(_ characters: String = " \t\n\r\0")
func String.trimmed(_ characters: String = " \t\n\r\0") -> String
Returns true if a string matches a regular expression, with optional extra options:
func matches(regex: String, options: NSRegularExpression.Options = []) -> Bool
Replaces matches for a regular expression with a replacement string:
replacing(regex: String, with replacement: String, options: NSString.CompareOptions) -> String
mutating func String.replace(regex: String, with replacement: String, options: NSString.CompareOptions)
Subscripts to let us read strings using integers and ranges:
subscript(idx: Int) -> String
subscript(range: Range<Int>) -> String
subscript(range: ClosedRange<Int>) -> String
subscript(range: CountablePartialRangeFrom<Int>) -> String
subscript(range: PartialRangeThrough<Int>) -> String
subscript(range: PartialRangeUpTo<Int>) -> String
Expands path components such as .
and ~
:
expandingPath() -> String
Creates a String
instance by downloading from a URL or by reading a local file:
String.init(url: String)
String.init?(file: String)
Removes a prefix or suffix from a string, if it exists:
deletingPrefix(_ prefix: String) -> String
deletingSuffix(_ suffix: String) -> String
Adds a prefix or suffix to a string, if it doesn’t already have it:
func String.withPrefix(_ prefix: String) -> String
func String.withSuffix(_ suffix: String) -> String
System functionality
Many functions will print a message and return a default value if their functionality failed. Set this to true if you want your script to terminate on these problems:
static var Brisk.haltOnError: Bool
Prints a message, or terminates the script if Brisk.haltOnError
is true:
func printOrDie(_ message: String)
Terminates the program, printing a message and returning an error code to the system:
func exit(_ message: String = "", code: Int = 0) -> Never
If Cocoa is available, this opens a file or folder using the correct app. This is helpful for showing the results of a script, because you can use open(getcwd())
:
func open(_ thing: String)
Extensions on URL
Add a string to a URL:
static func +(lhs: URL, rhs: String) -> URL
static func +=(lhs: inout URL, rhs: String)
XML parsing
Parses an instance of Data
or String
into an XML, or loads a file and does the same:
func parseXML(_ data: Data) -> XML.XMLNode?
func parseXML(_ string: String) -> XML.XMLNode?
func parseXML(from file: String) -> XML.XMLNode?
The resulting XMLNode
has the following properties:
tag
: The tag name used, e.g.<h1>
.data
: The text inside the tag, e.g.<h1>This bit is the data</h1>
attributes
: A dictionary containing the keys and values for all attributes.childNodes
: an array ofXMLNode
that belong to this node.
It also has a tiny subset of minidom functionality to make querying possible.
This finds all elements by a tag name, looking through all children, grandchildren, and so on:
func getElementsByTagName(_ name: String) -> [XMLNode]
This returns true if the current node has a specific attribute, or false otherwise:
func hasAttribute(_ name: String) -> Bool
This reads a single attribute, or sends back an empty string otherwise:
func getAttribute(_ name: String) -> String
Contribution guide
Any help you can offer with this project is most welcome – there are opportunities big and small so that someone with only a small amount of Swift experience can help.
Some suggestions you might want to explore, ordered by usefulness:
- Write some tests.
- Contribute example scripts.
- Add more helper functions.
What now?
This is a proof of concept scripting library for Swift developers. I don’t think it’s perfect, but I do at least hope it gives you some things to think about.
Some tips:
- If you already write scripts in Bash, Ruby, Python, PHP, JavaScript, etc, your muscle memory will always feel like it’s drawing you back there. That’s OK – learning anything new takes time.
- Stay away from macOS protected directories, such as your Desktop, Documents, and Photos.
- If you intend to keep scripts around for a long period of time, you can easily “upgrade” your code from Brisk’s helpers up to Foundation calls; nothing is overridden.
- The code is open source. Even if you end up not using Brisk at all, you’re welcome to read the code, learn from it, take it for your own projects, and so on.
Credits
Brisk was designed and built by Paul Hudson, and is copyright © Paul Hudson 2020. Brisk is licensed under the MIT license; for the full license please see the LICENSE file.
Swift, the Swift logo, and Xcode are trademarks of Apple Inc., registered in the U.S. and other countries.
If you find Brisk useful, you might find my website full of Swift tutorials equally useful: Hacking with Swift.