Runtime error String(format: ) when passing less variadic parameters

Viewed 293

Recently in firebase crashlytics we found out the following problem:

let a = "1-> %@" // Strings are located in Localized.strings, we already know them
let b = "1-> %@, 2-> %@" 

let aStr = String(format: a, "hello") // Works fine
let bStr = String(format: b, "hello") // Run time error on certain machine (M1, physical device)

Looks like when passing less variadic parameters into String(format:) you get runtime crashes on M1 and physical devices.

I was wondering if there is any way to avoid these runtime errors for the strings coming from localised.strings.

The best would be to catch this on compile time. How would you face this problem?

1 Answers

Not providing enough parameters to a format string will result in undefined behaviour (it might crash, it might not - there's no way to know). It's a programming error but due to the dynamic nature of them, you won't get a compile-time warning at the moment.

However, writing a small helper we can deterministically crash if not enough format parameters are provided. Note this is not a perfect implementation and may need tweaking to suit your requirements.

extension String {

    // https://en.wikipedia.org/wiki/Printf_format_string
    private static var formatCharacters: Set<Character> {
        "@diufFeEgGxXoscpaAn".reduce(into: []) { $0.insert($1) }
    }

    static func safely(format template: String, _ params: CVarArg...) -> String {
        var potentialPattern = false
        var patternCount = 0
        for char in template {
            switch char {
            case "%":
                // %% or %
                potentialPattern.toggle()
            case let x where formatCharacters.contains(x) && potentialPattern:
                patternCount += 1
                potentialPattern = false
            case let x where !x.isWhitespace:
                potentialPattern = false
            default:
                break
            }
        }
        assert(
            patternCount <= params.count,
            "Not enough parameters passed to format String. Found \(params.count), expected at least \(patternCount)."
        )
        return String(format: template, arguments: params)
    }
    
}

We then get a crash if not enough parameters are provided:

let f = String.safely(format: "%@ %@", "nice")
// Assertion failed: Not enough parameters passed to format String. Found 1, expected at least 2.
Related