Text Statistics & Readability — Swift source
Count words, sentences, paragraphs, characters, lines, and reading time, plus Flesch Reading Ease and Flesch-Kincaid grade-level readability scores.
This is the Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.
// text-stats — text statistics & readability: chars, words, sentences, syllables, Flesch. Language: Swift (5.9+). Port of src/lib/textStats.ts — String.count counts Characters (grapheme clusters), UTF-16 units only differ for astral-plane text.
import Foundation
/// Full analysis — readability stays nil when words or sentences are too few.
struct TextStats {
var characters = 0, charactersNoSpaces = 0, words = 0, sentences = 0, paragraphs = 0
var lines = 0, syllables = 0, readingTimeMs = 0, speakingTimeMs = 0
var fleschReadingEase: Double? = nil, fleschKincaidGrade: Double? = nil, readabilityLabel: String? = nil
}
func isWordChar(_ c: Character) -> Bool { // ASCII alnum, apostrophes (U+2019 keeps contractions whole), hyphen
("a"..."z").contains(c) || ("A"..."Z").contains(c) || ("0"..."9").contains(c) || c == "'" || c == "’" || c == "-"
}
/// Syllables in one word via the vowel-group heuristic.
func countSyllables(_ word: String) -> Int {
let w = word.lowercased().filter { if let a = $0.asciiValue { a >= 97 && a <= 122 } else { false } }
if w.isEmpty { return 0 }
if w.count <= 3 { return 1 }
var s = Substring(w)
let notLVowelish = { (c: Character) -> Bool in !"laeiouy".contains(c) }
// drop a silent trailing e/ed/es — 'l' and vowels keep their syllable ("~le")
if s.hasSuffix("es"), let before = s.dropLast(2).last, notLVowelish(before) { s = s.dropLast(2) }
else if s.hasSuffix("ed") { s = s.dropLast(2) }
else if s.hasSuffix("e"), let before = s.dropLast().last, notLVowelish(before) { s = s.dropLast() }
if s.hasPrefix("y") { s = s.dropFirst() }
var groups = 0, inVowel = false
for c in s { let v = "aeiouy".contains(c); if v && !inVowel { groups += 1 }; inVowel = v }
return max(1, groups)
}
func analyze(_ input: String?) -> TextStats {
let text = input ?? ""
var st = TextStats()
st.characters = text.count
st.charactersNoSpaces = text.filter { !$0.isWhitespace }.count
var words = [String](); var current = "" // words = maximal runs of word characters
for c in text {
if isWordChar(c) { current.append(c) }
else if !current.isEmpty { words.append(current); current = "" }
}
if !current.isEmpty { words.append(current) }
st.words = words.count
var run = 0 // sentences = runs of [.!?] followed by whitespace or end; min 1 when words > 0
for c in text {
if c == "." || c == "!" || c == "?" { run += 1 }
else { if run > 0 && c.isWhitespace { st.sentences += 1 }; run = 0 }
}
if run > 0 { st.sentences += 1 }
st.sentences = words.isEmpty ? 0 : max(1, st.sentences)
var blank = true, nlRun = 0 // paragraphs = non-blank chunks split on runs of 2+ newlines
for c in text {
if c == "\n" { nlRun += 1; if nlRun >= 2 && !blank { st.paragraphs += 1; blank = true } }
else { if !c.isWhitespace { blank = false }; nlRun = 0 }
}
if !blank { st.paragraphs += 1 }
st.paragraphs = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? 0 : st.paragraphs
st.lines = text.isEmpty ? 0 : text.filter { $0 == "\n" }.count + 1
st.syllables = words.reduce(0) { $0 + countSyllables($1) }
st.readingTimeMs = Int((Double(st.words) / 200.0 * 60_000).rounded()) // 200 wpm
st.speakingTimeMs = Int((Double(st.words) / 130.0 * 60_000).rounded()) // 130 wpm
if st.words > 0 && st.sentences > 0 {
let wps = Double(st.words) / Double(st.sentences), spw = Double(st.syllables) / Double(st.words)
let fre = ((206.835 - 1.015 * wps - 84.6 * spw) * 10).rounded() / 10
st.fleschReadingEase = fre
st.fleschKincaidGrade = ((0.39 * wps + 11.8 * spw - 15.59) * 10).rounded() / 10
st.readabilityLabel = fre >= 80 ? "Very Easy" : fre >= 70 ? "Easy" : fre >= 60 ? "Standard"
: fre >= 50 ? "Fairly Hard" : fre >= 30 ? "Hard" : "Very Hard"
}
return st
}
for text in ["The quick brown fox jumps over the lazy dog.",
"Hi.\n\nMy name is Inigo Montoya. You killed my father; prepare to die!"] {
let s = analyze(text)
let flesch = s.readabilityLabel.map { String(format: "%.1f (%@)", s.fleschReadingEase ?? 0, $0) } ?? "n/a"
let grade = s.fleschKincaidGrade.map { String(format: "%.1f", $0) } ?? "n/a"
print("chars=\(s.characters) nospace=\(s.charactersNoSpaces) words=\(s.words) " +
"sentences=\(s.sentences) paragraphs=\(s.paragraphs) lines=\(s.lines) " +
"syllables=\(s.syllables) reading=\(s.readingTimeMs)ms speaking=\(s.speakingTimeMs)ms " +
"flesch=\(flesch) grade=\(grade)")
}
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 →