swift-formatstyle — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited swift-formatstyle (Agent Skill) and scored it 45/100 (orange). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 2 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 2 flagged
A base64 string of 128+ characters appears in a documentation file. Encoded prompt injection hides the hostile instruction in base64 — invisible to keyword filters — and relies on the agent's ability to decode it at runtime. There is no normal authoring reason to embed a multi-hundred-byte base64 blob in skill docs.
*.sig, SIGNATURES) outside the documentation.Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Format values for human-readable display using the FormatStyle protocol and Foundation's concrete format styles. Replaces legacy Formatter subclasses with a type-safe, composable, cacheable API.
Locale-aware display is an i18n concern even when the app is not adding new languages. When reviewing user-facing FormatStyle output or SwiftUI Text(_:format:), always include an actionable preview/test step for the exact rendered UI in representative locales such as en_US, de_DE, ar_SA, and ja_JP; check separators, numbering systems, calendars, currency and unit conventions, text direction, and layout-sensitive output. Keep this skill focused on FormatStyle, ParseableFormatStyle, parsing, and reusable formatter API design; route broader localization work such as String Catalogs, bundles, plurals, localized copy, and RTL layout review to ios-localization. Do not use "not adding languages" as the reason to skip ios-localization; locale-sensitive formatting can be a localization review issue without translation work.
Docs: FormatStyle
| Type | Style Access | Example |
|---|---|---|
Int, Double | .number | 42.formatted(.number.precision(.fractionLength(2))) → "42.00" |
Decimal | .number, .percent, .currency(code:) | Decimal(string: "0.1")!.formatted(.percent) -> "10%" |
| Currency | .currency(code:) | 29.99.formatted(.currency(code: "USD")) -> "$29.99" |
| Percent | .percent | 0.85.formatted(.percent) → "85%" |
Date | .dateTime | Date.now.formatted(.dateTime.month().day().year()) |
| Date range | .interval | (date1..<date2).formatted(.interval) |
| Relative date | .relative(presentation:unitsStyle:) | date.formatted(.relative(presentation: .named)) → "yesterday" |
Duration | .time(pattern:) | Duration.seconds(3661).formatted(.time(pattern: .hourMinuteSecond)) → "1:01:01" |
Duration | .units(allowed:width:) | Duration.seconds(90).formatted(.units(allowed: [.minutes, .seconds])) → "1 min, 30 sec" |
Measurement | .measurement(width:) | Measurement(value: 72, unit: UnitTemperature.fahrenheit).formatted(.measurement(width: .abbreviated)) |
PersonNameComponents | .name(style:) | name.formatted(.name(style: .short)) → "Tom" |
[String] | .list(type:width:) | ["A","B","C"].formatted(.list(type: .and)) → "A, B, and C" |
| Byte count | .byteCount(style:) | Int64(1_048_576).formatted(.byteCount(style: .memory)) → "1 MB" |
URL | .url | url.formatted(.url.scheme(.never).host().path()) |
// Default locale-aware formatting
let n = 1234567.formatted() // "1,234,567" (en_US)
// Precision
1234.5.formatted(.number.precision(.fractionLength(0...2))) // "1,234.5"
1234.5.formatted(.number.precision(.significantDigits(3))) // "1,230"
// Rounding
1234.formatted(.number.rounded(rule: .down, increment: 100)) // "1,200"
// Grouping
1234567.formatted(.number.grouping(.never)) // "1234567"
// Notation
1_200_000.formatted(.number.notation(.compactName)) // "1.2M"
42.formatted(.number.notation(.scientific)) // "4.2E1"
// Sign display
(-42).formatted(.number.sign(strategy: .always())) // "+42" / "-42"
// Locale override
42.formatted(.number.locale(Locale(identifier: "de_DE"))) // "42"Docs: IntegerFormatStyle, FloatingPointFormatStyle
Use Decimal.FormatStyle for exact decimal values, especially money-like values that should not pass through binary floating-point.
let amount = Decimal(string: "12345.67")!
amount.formatted(.number) // "12,345.67" (en_US)
amount.formatted(.number.grouping(.never)) // "12345.67"
Decimal(string: "0.1")!.formatted(.percent) // "10%"
amount.formatted(.currency(code: "USD")) // "$12,345.67"
// Parsing with the same style
let price = try? Decimal("$3,500.63", format: .currency(code: "USD"))Docs: Decimal.FormatStyle
29.99.formatted(.currency(code: "USD")) // "$29.99"
29.99.formatted(.currency(code: "EUR")) // "€29.99"
29.99.formatted(.currency(code: "JPY")) // "¥30"
// Customize precision
let style = FloatingPointFormatStyle<Double>.Currency(code: "USD")
.precision(.fractionLength(0))
1234.56.formatted(style) // "$1,235"0.85.formatted(.percent) // "85%"
0.8567.formatted(.percent.precision(.fractionLength(1))) // "85.7%"
42.formatted(.percent) // "42%" (integer)let now = Date.now
// Components
now.formatted(.dateTime.year().month().day()) // "Apr 22, 2026"
now.formatted(.dateTime.hour().minute()) // "4:30 PM"
now.formatted(.dateTime.weekday(.wide).month(.wide).day()) // "Wednesday, April 22"
// Predefined styles
now.formatted(date: .long, time: .shortened) // "April 22, 2026 at 4:30 PM"
now.formatted(date: .abbreviated, time: .omitted) // "Apr 22, 2026"
// ISO 8601
now.formatted(.iso8601) // "2026-04-22T16:30:00Z"
// Relative
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: .now)!
yesterday.formatted(.relative(presentation: .named)) // "yesterday"
yesterday.formatted(.relative(presentation: .numeric)) // "1 day ago"
// Treat relative strings as standalone text; embedding them inside another
// sentence can be grammatically wrong in some locales. Recommend previewing or
// testing the exact screen in representative locales before approving copy.
Text(yesterday, format: .relative(presentation: .named))
// Interval
(date1..<date2).formatted(.interval.month().day().hour().minute())
// Components (countdown-style)
(date1..<date2).formatted(.components(style: .wide, fields: [.day, .hour]))
// "2 days, 5 hours"Docs: Date.FormatStyle, Date.RelativeFormatStyle, Date.IntervalFormatStyle
Date.AnchoredRelativeFormatStyle formats relative to a fixed anchor date rather than the current moment. It requires iOS 18+.
Docs: Date.AnchoredRelativeFormatStyle
Duration (iOS 16+) has two format styles:
Docs: Duration.TimeFormatStyle, Duration.UnitsFormatStyle
let d = Duration.seconds(3661)
d.formatted(.time(pattern: .hourMinuteSecond)) // "1:01:01"
d.formatted(.time(pattern: .hourMinute)) // "1:01"
d.formatted(.time(pattern: .minuteSecond)) // "61:01"
// Fractional seconds
Duration.seconds(3.75).formatted(
.time(pattern: .minuteSecond(padMinuteToLength: 2, fractionalSecondsLength: 2))
) // "00:03.75"Duration.seconds(3661).formatted(
.units(allowed: [.hours, .minutes, .seconds], width: .abbreviated)
) // "1 hr, 1 min, 1 sec"
Duration.seconds(90).formatted(
.units(allowed: [.minutes, .seconds], width: .wide)
) // "1 minute, 30 seconds"
Duration.seconds(90).formatted(
.units(allowed: [.minutes, .seconds], width: .narrow)
) // "1m 30s"
// Limit unit count
Duration.seconds(3661).formatted(
.units(allowed: [.hours, .minutes, .seconds], width: .abbreviated, maximumUnitCount: 2)
) // "1 hr, 1 min"let temp = Measurement(value: 72, unit: UnitTemperature.fahrenheit)
temp.formatted(.measurement(width: .wide)) // "72 degrees Fahrenheit"
temp.formatted(.measurement(width: .abbreviated)) // "72°F"
temp.formatted(.measurement(width: .narrow)) // "72°"
let dist = Measurement(value: 5, unit: UnitLength.kilometers)
dist.formatted(.measurement(width: .abbreviated, usage: .road)) // "3.1 mi" (en_US)Docs: Measurement.FormatStyle
var name = PersonNameComponents()
name.givenName = "Thomas"
name.familyName = "Clark"
name.middleName = "Louis"
name.namePrefix = "Dr."
name.nickname = "Tom"
name.nameSuffix = "Esq."
name.formatted(.name(style: .long)) // "Dr. Thomas Louis Clark Esq."
name.formatted(.name(style: .medium)) // "Thomas Clark"
name.formatted(.name(style: .short)) // "Tom"
name.formatted(.name(style: .abbreviated)) // "TC"Style resolution follows priority: script → user preferences → locale → developer setting.
Docs: PersonNameComponents.FormatStyle
["Alice", "Bob", "Charlie"].formatted(.list(type: .and))
// "Alice, Bob, and Charlie"
["Alice", "Bob", "Charlie"].formatted(.list(type: .or))
// "Alice, Bob, or Charlie"
// With member formatting
[1, 2, 3].formatted(.list(memberStyle: .number, type: .and))
// "1, 2, and 3"
// Narrow width
["A", "B", "C"].formatted(.list(type: .and, width: .narrow))
// "A, B, C"Docs: ListFormatStyle
Int64(1_048_576).formatted(.byteCount(style: .memory)) // "1 MB"
Int64(1_048_576).formatted(.byteCount(style: .file)) // "1 MB"
Int64(1_048_576).formatted(.byteCount(style: .binary)) // "1 MiB"Docs: ByteCountFormatStyle
URL.FormatStyle requires iOS 16+. The default style includes scheme, host, and path. Treat port, query, and fragment as opt-in display components; add .port(.always), .query(.always), or .fragment(.always) only when those components should be visible.
let url = URL(string: "https://www.example.com:8080/path?q=1#section")!
url.formatted()
// "https://www.example.com/path"
url.formatted(.url.scheme(.never).host().path())
// "www.example.com/path"
url.formatted(.url.scheme(.never).host().path().query(.always))
// "www.example.com/path?q=1"
url.formatted(.url.scheme(.never).host().path().fragment(.always))
// "www.example.com/path#section"When auditing URL component choices, recommend previewing or testing the exact rendered URL text in representative locales, especially when choosing whether to show or hide scheme, path, query, port, or fragment.
Docs: URL.FormatStyle
Text accepts a format: parameter, keeping formatting out of the view model.
// Inline format style
Text(price, format: .currency(code: "USD"))
Text(date, format: .dateTime.month().day().year())
Text(duration, format: .units(allowed: [.minutes, .seconds]))
// Timer-style (live updating)
Text(.now, style: .timer)
Text(.now, style: .relative)
Text(timerInterval: start...end)Prefer `Text(_:format:)` over string interpolation — it allows SwiftUI to re-render only the formatted value and supports accessibility scaling. For every SwiftUI formatted Text review, include a representative-locale preview, UI test, or snapshot test recommendation for the exact screen.
Conform to FormatStyle for domain-specific formatting. Conform to ParseableFormatStyle if you also need parsing. FormatStyle refines Decodable, Encodable, and Hashable, and Foundation caches identical customized style instances, so reusable value-style formatters are cheap.
struct AbbreviatedCountStyle: FormatStyle {
func format(_ value: Int) -> String {
switch value {
case ..<1_000:
return "\(value)"
case 1_000..<1_000_000:
return String(format: "%.1fK", Double(value) / 1_000)
default:
return String(format: "%.1fM", Double(value) / 1_000_000)
}
}
}
extension FormatStyle where Self == AbbreviatedCountStyle {
static var abbreviatedCount: AbbreviatedCountStyle { .init() }
}
// Usage
let followers = 12_500
Text(followers, format: .abbreviatedCount) // "12.5K"For parseable custom styles, pair formatting with a parse strategy and use the same conventions in both directions. Prefer this only when users edit or import the formatted value; display-only styles should stay as FormatStyle.
Docs: ParseableFormatStyle
| Mistake | Fix |
|---|---|
Using legacy NumberFormatter / DateFormatter in new code | Use FormatStyle (iOS 15+). Foundation caches format style instances automatically. |
String interpolation for formatted numbers in Text | Use Text(value, format:) for locale correctness and accessibility |
| Hardcoding locale in format styles | Omit .locale() to inherit the user's current locale by default |
Assuming URL.formatted() preserves query strings, ports, or fragments | Default URL formatting includes scheme, host, and path only; opt in with .query(.always), .port(.always), or .fragment(.always) |
| Embedding relative date output inside larger sentences | Use Date.RelativeFormatStyle output as standalone text; localized grammar may not fit interpolation |
| Forgetting availability checks | URL.FormatStyle and Duration format styles require iOS 16+; Date.AnchoredRelativeFormatStyle requires iOS 18+ |
Using .time(pattern:) for labeled duration display | Use .units(allowed:width:) for "1 hr, 30 min" style output |
Creating Formatter instances in body or tight loops | FormatStyle instances are value types cached by Foundation; safe to create inline |
Formatting Duration with DateComponentsFormatter | Use Duration.TimeFormatStyle or Duration.UnitsFormatStyle directly |
Ignoring usage: parameter for measurements | Specify .road, .asProvided, etc. for locale-aware unit conversion |
| Using binary floating-point for exact decimal display/parsing | Use Decimal.FormatStyle and matching parse strategies for exact decimal values |
FormatStyle used instead of legacy Formatter subclasses for iOS 15+ targetsURL.FormatStyle and Duration styles gated to iOS 16+; anchored relative dates gated to iOS 18+Text(_:format:) used instead of pre-formatting strings for SwiftUI textText includes an explicit representative-locale preview/test recommendationDecimal.FormatStyle when exact decimal formatting or parsing mattersDuration.TimeFormatStyle or Duration.UnitsFormatStyleusage: for user-facing displayCodable + Hashable for caching~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.