Skip to content

Instantly share code, notes, and snippets.

@mklbtz
Last active May 22, 2018 05:14
Show Gist options
  • Save mklbtz/8c7320e6c78a3131ab218b2192ec8d7f to your computer and use it in GitHub Desktop.
Save mklbtz/8c7320e6c78a3131ab218b2192ec8d7f to your computer and use it in GitHub Desktop.
Some Unix-y command line tools written in Swift

In the course of time, I found myself wanting to run RSpec automatically in response to file changes. I found a nice tool called fswatch to handle the file events, but I wanted a "smarter" pipeline. Following the Unix philosophy, I wrote a few tiny command line tools which I've provided the source to.

  • specname — maps a file in a rails project to its corresponding spec file.
  • debounce — makes sure multiple rapid saves don't trigger as many RSpec runs.
  • throttle — could be used to collect multiple file changes into one RSpec run, but I haven't figured that last part out yet.

The core functions are fully generic and have no dependencies on anything but Foundation, so they could be easily incorporated into a larger project if there's a use for them.

You can quickly run these scripts or install them as binaries using JohnSundell/Marathon.

marathon install debounce.swift
import Foundation
enum DebounceResult {
case handle, ignore
}
// Returns a function that will ignore duplicate inputs for the duration.
// The duration is tracked independently for unique inputs.
// The duration for an input is reset every time a duplicate of it is received.
func debouncing<T>(
for duration: DispatchTimeInterval,
on queue: DispatchQueue = .global(qos: .userInitiated)
) -> (T) -> DebounceResult
where T: Hashable
{
var memory: [T: DispatchWorkItem] = [:]
func remember(_ input: T) {
memory[input]?.cancel()
let forgetInput = DispatchWorkItem { memory[input] = nil }
queue.asyncAfter(wallDeadline: .now() + duration, execute: forgetInput)
memory[input] = forgetInput
}
return { input in
defer { remember(input) }
return memory.keys.contains(input) ? .ignore : .handle
}
}
let duration = CommandLine.arguments.dropFirst().first.flatMap(Int.init) ?? 1
let debounce = debouncing(for: .seconds(duration)) as (String) -> DebounceResult
while let input = readLine() {
switch debounce(input) {
case .handle: print(input)
case .ignore: continue
}
}
import Foundation
enum Err: Error {
case message(String)
case silent
}
extension FileHandle: TextOutputStream {
public func write(_ string: String) {
guard let data = string.data(using: .utf8) else { return }
self.write(data)
}
}
func printError(_ str: String) {
var stderr = FileHandle.standardError
print(str, to: &stderr)
}
func printOut(_ str: String) {
var stdout = FileHandle.standardOutput
print(str, to: &stdout)
}
// Converts a ruby file path, which we assume to be in a Rails app,
// to its corresponding spec file path.
// Errors thrown indicate a severity.
// Some errors are worth logging and others may as well be silenced.
func specPath(for inputPath: String) throws -> String {
guard inputPath.hasSuffix(".rb"), FileManager.default.fileExists(atPath: inputPath) else {
throw Err.silent
}
if inputPath.hasSuffix("_spec.rb") { return inputPath }
var components = (inputPath as NSString).pathComponents
if let idx = components.index(of: "app") {
// "app" specs get collapsed into "spec/<type>"
components.remove(at: idx)
components.insert("spec", at: idx)
} else if let idx = components.index(of: "lib") {
// "lib" specs stay nested under "spec/lib"
components.insert("spec", at: idx)
} else {
throw Err.message("does not include \"app\" or \"lib\": \"\(inputPath)\"")
}
var filename = components.removeLast()
filename.removeLast(3)
filename += "_spec.rb"
components.append(filename)
let specPath = NSString.path(withComponents: components) as String
guard FileManager.default.fileExists(atPath: specPath) else {
throw Err.message("output does not exist: \"\(specPath)\"")
}
return specPath
}
while let inPath = readLine() {
do {
printOut(try specPath(for: inPath))
} catch .message(let msg) as Err {
printError(msg)
} catch .silent as Err {
continue
}
}
import Foundation
// Calls to `process` are throttled so that the callback actually invoked
// once no input is receivced for the specified delay.
// Call `finish` to exit once all processing is complete.
struct Throttle<T> {
let process: (T) -> Void
let finish: () -> Never
init(
delay: DispatchTimeInterval,
on queue: DispatchQueue = .init(label: "Throttle_sync_queue", qos: .userInitiated, attributes: []),
process callback: @escaping (T) -> ()
) {
var memory: [T] = []
var pendingWork: DispatchWorkItem? = nil
self.process = { input in
memory.append(input)
pendingWork?.cancel()
pendingWork = .init {
while !memory.isEmpty {
callback(memory.removeFirst())
}
}
queue.asyncAfter(wallDeadline: .now() + delay, execute: pendingWork!)
}
self.finish = {
DispatchQueue.main.async {
pendingWork?.wait()
exit(0)
}
dispatchMain()
}
}
}
let delay = CommandLine.arguments.dropFirst().first.flatMap(Int.init) ?? 1
let throttle = Throttle<String>(delay: .seconds(delay)) { print($0) }
while let input = readLine() {
throttle.process(input)
}
throttle.finish()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment