Sort Lines & Remove Duplicates — Swift source
Alphabetize, reverse, shuffle, dedupe, or length-sort lines of text. Supports case-insensitive and natural sorting (file2 before file10).
This is the Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.
// sort-lines — multi-mode line sorter. Language: Swift (5.9+, stdlib only). Port of src/lib/sortLines.ts — same contract as this dir's go.go (the live Go twin): split on "\n", apply the mode (asc/desc/length-asc/length-desc/reverse/shuffle/unique), join back. Swift's sorted(by:) is not guaranteed stable, so ordered modes decorate with the original index (the TS sort is stable — ties keep input order). Shuffle uses mulberry32, so a seed reproduces the TS order.
enum SortMode: String { case asc, desc, lengthAsc = "length-asc", lengthDesc = "length-desc", reverse, shuffle, unique }
struct SortOptions { // TS defaults: case sensitive, no trim, no natural, seed 1
var caseSensitive = true, trim = false, natural = false, seed = 1
}
struct SortResult { let lines: [String]; let text: String; let removedDuplicates: Int }
// mulberry32 — deterministic PRNG (not cryptographic); a seed reproduces the same shuffle.
struct Mulberry32 {
var a: UInt32
init(seed: Int) { a = UInt32(bitPattern: Int32(truncatingIfNeeded: seed)) }
mutating func next() -> Double {
a &+= 0x6d2b79f5
var t = (a ^ (a >> 15)) &* (1 | a)
t = (t &+ (t ^ (t >> 7)) &* (61 | t)) ^ t
return Double(t ^ (t >> 14)) / 4294967296.0
}
}
private func isDigit(_ c: Character) -> Bool { c >= "0" && c <= "9" }
private func trimWS(_ s: String) -> String { // spaces / tabs / CR both ends
var t = Substring(s)
while let f = t.first, f == " " || f == "\t" || f == "\r" { t = t.dropFirst() }
while let l = t.last, l == " " || l == "\t" || l == "\r" { t = t.dropLast() }
return String(t)
}
// Maximal digit / non-digit runs; "" yields [""], like the TS ?? fallback.
private func chunks(_ s: String) -> [Substring] {
var out: [Substring] = [], start = s.startIndex, i = s.startIndex, digit = false
while i < s.endIndex {
let d = isDigit(s[i])
if i == s.startIndex { digit = d } else if d != digit { out.append(s[start..<i]); start = i; digit = d }
i = s.index(after: i)
}
out.append(s[start...])
return out
}
func plainCompare(_ a: String, _ b: String, caseSensitive: Bool) -> Int {
let x = caseSensitive ? a : a.lowercased(), y = caseSensitive ? b : b.lowercased()
return x == y ? 0 : x < y ? -1 : 1
}
// Natural order: compare ASCII digit / non-digit runs chunk-wise so numbers order
// by value — "file2" sorts before "file10".
func naturalCompare(_ a: String, _ b: String, caseSensitive: Bool) -> Int {
let aa = chunks(caseSensitive ? a : a.lowercased())
let bb = chunks(caseSensitive ? b : b.lowercased())
for i in 0..<min(aa.count, bb.count) {
let x = aa[i], y = bb[i]
let dn = x.first.map(isDigit) ?? false, dm = y.first.map(isDigit) ?? false
if dn != dm { return x < y ? -1 : 1 } // digit run vs text run: raw compare
if dn {
let vx = x.drop { $0 == "0" }, vy = y.drop { $0 == "0" } // numeric value: strip leading zeros
if vx.count != vy.count { return vx.count < vy.count ? -1 : 1 }
if vx != vy { return vx < vy ? -1 : 1 }
} else if x != y {
return x < y ? -1 : 1
}
}
return aa.count - bb.count
}
func sortLines(_ input: String, _ mode: SortMode, _ opts: SortOptions = SortOptions()) -> SortResult {
var lines = input.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
if opts.trim { lines = lines.map(trimWS) }
let norm = { (s: String) -> String in opts.caseSensitive ? s : s.lowercased() }
var removed = 0
switch mode {
case .unique: // keep each normalized line's first occurrence; count the rest
var seen = Set<String>(), out: [String] = []
for l in lines {
if seen.contains(norm(l)) { removed += 1 } else { seen.insert(norm(l)); out.append(l) }
}
lines = out
case .shuffle: // Fisher-Yates with the seeded PRNG -> reproducible order
var rng = Mulberry32(seed: opts.seed)
var i = lines.count - 1
while i > 0 { let j = Int(rng.next() * Double(i + 1)); lines.swapAt(i, j); i -= 1 }
case .reverse:
lines.reverse()
case .lengthAsc, .lengthDesc: // stable by length (ties keep input order), then reverse for desc
let decorated = Array(lines.enumerated())
let ordered = decorated.sorted(by: { (a, b) -> Bool in
if a.element.count != b.element.count { return a.element.count < b.element.count }
return a.offset < b.offset
}).map { $0.element }
lines = mode == .lengthAsc ? ordered : ordered.reversed()
case .asc, .desc: // stable in both directions — ties keep input order
let dir = mode == .asc ? 1 : -1
lines = lines.enumerated().sorted { a, b in
let c = opts.natural ? naturalCompare(a.element, b.element, caseSensitive: opts.caseSensitive)
: plainCompare(a.element, b.element, caseSensitive: opts.caseSensitive)
return c != 0 ? c * dir < 0 : a.offset < b.offset
}.map { $0.element }
}
return SortResult(lines: lines, text: lines.joined(separator: "\n"), removedDuplicates: removed)
}
let text = "pear\napple\nBanana\napple\nfig10\nfig2"
print("asc: " + sortLines(text, .asc).text)
print("ci-asc: " + sortLines(text, .asc, SortOptions(caseSensitive: false)).text)
let u = sortLines(text, .unique, SortOptions(caseSensitive: false))
print("uniq: \(u.text) (removed \(u.removedDuplicates))")
print("shuf-7: " + sortLines(text, .shuffle, SortOptions(seed: 7)).text)
print("nat-ci: " + sortLines(text, .asc, SortOptions(caseSensitive: false, natural: true)).text)
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →