Adding a Counter to URL in Swift

02 Apr 2019 ∞

I recently ran into a bug in a project when attempting to move a file:

The File.eip” couldn’t be moved to “.archive” because an item with the same name already exists.

This little error propagated all the way to the top and terminating my Launch Agent1. Being a background task I couldn't ask the user what they'd like to do about this error, so the method moving the file had to handle it.

The obvious solution is to add a file counter to the end, so File.eip becomes File 1.eip. I figured it would also make sense to make this incrementing method an extension of URL, similar to .appendPathComponent(:)

Adding a Counter

The whole method looks like this:

func incrementingCounter(starting count: Int = 1, by increment: Int = 1, format: String = "%01d", delimiter: String = " ") -> URL {

    // Break apart the URL
    let ext = self.pathExtension
    var fileName = self.deletingPathExtension().lastPathComponent

    let tempDest = self.deletingLastPathComponent()
    var counter = count

    // Find any suffix digits
    fileCounter: if let digitRange = fileName.range(of: "\(delimiter)\\d+$", options: .regularExpression) {

        let delimiterSet = CharacterSet(charactersIn: delimiter)

        // Extract the digits
        let subString = String(fileName[digitRange]).trimmingCharacters(in: delimiterSet)
        guard let fileCounter = Int(subString) else { break fileCounter}

        // Increment the counter
        counter = fileCounter + increment

        // Remove the existing counter
        fileName.removeSubrange(digitRange)

    }

    // Append the counter with a space
    let formattedCounter = String(format: format, counter)
    let newName = fileName.appending("\(delimiter)\(formattedCounter)")
    return tempDest.appendingPathComponent(newName).appendingPathExtension(ext)

}

and comes in two flavors:

  • incrementingCounter(starting:by:format:delimiter:)
  • incrementCounter(starting:by:format:delimiter:)

The first constructs a new URL and the second adds the counter in place. The parameters allow the user to customize how the incrementing takes place. The user can specify the initial number, how much to increment by, as well as the string format of the counter and the delimiter to separate the counter from the file name.

Let's look at what each part does.

To find the counter we first break the URL apart so we can deal with just the file name.

let ext = self.pathExtension
var fileName = self.deletingPathExtension().lastPathComponent

let tempDest = self.deletingLastPathComponent()

Once we have the file name we need to check if it already has a counter. To do this I'm using a small regular expression, "\(delimiter)\\d+$", which matches our delimiter followed by number. The $ anchors the expression to the end of the string so we don't match files that contain numbers in their file names.

Specifying the delimiter provides for more flexibility. By default it's a space, but that may not always be what we need. I regularly work with files that use _v\d{4} as a counter.

fileName.range(of: "\(delimiter)\\d+$", options: .regularExpression)

This method returns an optional range for the matching characters. If there is a range we attempt to convert the range into an integer.

let delimiterSet = CharacterSet(charactersIn: delimiter)

let subString = String(fileName[digitRange]).trimmingCharacters(in: delimiterSet)
guard let fileCounter = Int(subString) else { break fileCounter}

The break here is important: it uses a labeled break which lets us exit the current scope (in the case our if for the range). I'm using the labeled break to handle the case where a number can't be made into an integer2.

In this case it will just append a new counter to the end of the file name.

Next we increment the counter, and importantly, remove the existing counter.

// Increment the counter
counter = fileCounter + increment

// Remove the existing counter
fileName.removeSubrange(digitRange)

The last step in our method is to format the counter and append it to the file name.

// Append the counter with a space
let formattedCounter = String(format: format, counter)
let newName = fileName.appending(" \(formattedCounter)")
return tempDest.appendingPathComponent(newName).appendingPathExtension(ext)

The whole file is available over on GitHub


  1. Now I have tests that try to copy a duplicate file.

  2. I haven't figured out how to write a test for this