Skip to content

About

Line, word and character diffs, unified patches that apply, and similarity — Levenshtein, Jaro-Winkler, fuzzy matching — as typed values, on any Apple platform.

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

7 Commits

Folders and files

Repository files navigation

Swift Text Diff

What changed between two texts — by line, word or character — and how alike two strings are, as typed values, on any Apple platform.

import TextDiff

TextDiff.unified("alpha\nbeta\ngamma", "alpha\ndelta\ngamma")
// @@ -1,3 +1,3 @@
//  alpha
// -beta
// +delta
//  gamma

TextDiff.inline("The quick brown fox", "The slow red fox")
// The [-quick brown-]{+slow red+} fox

TextDiff.stats("a\nb\nc", "a\nx\nc").changed          // 1 — not "1 added, 1 removed"
try Patch.apply(diff, to: original)                     // the new text, exactly, or a named refusal

Levenshtein.distance("kitten", "sitting")               // 3
JaroWinkler.similarity("MARTHA", "MARHTA")              // 0.961
Fuzzy.bestMatch("zurich", in: ["Geneva", "Zürich"])     // Zürich, score 1

Why

Question The wrong answer, and why it is tempting
How many lines changed? "3 added, 3 removed." That is the edit script, not the summary; a replaced line is one change, and stats pairs them off.
Do these two texts differ? !=, which says yes to a trailing newline, a CRLF, or a second space. The options say which of those you meant.
Which word changed? A line diff, which replaces the whole line. The word diff keeps the spacing as tokens, so the inline form reproduces the sentence.
Is café one edit from cafe? Two or three, counting scalars. Distances here count characters as a person sees them.
Which entry did they mean? contains, which finds nothing for Genva. Jaro–Winkler finds Geneva, and folding finds Zürich for zurich.
Can I put this diff back? Splicing lines by number, which corrupts the file silently when the base moved. Patch checks every context line and refuses with the line named.

Installation

.package(url: "https://github.com/arraypress/swift-text-diff.git", from: "1.0.0")

Foundation only. No dependencies, every Apple platform, macOS 14+.

Lines, words, characters

TextDiff.diff(old, new)                       // DiffResult: operations, added, removed, unchanged
TextDiff.words(old, new)                      // tokens are words and runs of whitespace
TextDiff.characters(old, new)                 // one grapheme cluster each

All three produce the same DiffResult: an ordered list of .equal, .insert and .delete operations, each carrying its token and its 0-based position, from Myers' algorithm in linear space. Reading the operations in order rebuilds either text — keep the equal and deleted tokens for the old, the equal and inserted for the new.

The line rule: CRLF and a lone CR become LF, then the text splits on LF keeping the trailing element. A trailing newline is a real, empty final line, so a\nb and a\nb\n differ. An empty text is one empty line.

DiffOptions decides what "the same" means — ignoreCase, ignoreWhitespace (collapse runs and trim on lines; any run of whitespace equals any other in words and characters), ignoreBlankLines (lines only). Every option affects the comparison alone; the operations always carry the original text.

Unified, and back again

TextDiff.unified(old, new, context: 3)
TextDiff.unified(old, new, oldName: "before.txt", newName: "after.txt")   // with --- / +++
try Patch.parse(diff)                          // [Hunk]
try Patch.apply(diff, to: old)                 // new

unified writes the @@ -a,b +c,d @@ form every tool reads, with the GNU convention for one-sided hunks. Patch reads it back — and what git and diff write too: file headers, a function name after the header, \ No newline at end of file, a count-less @@ -5 +5 @@, an empty context line whose leading space an editor stripped. Applying checks every context and deleted line against the text and refuses a mismatch with the hunk and line named, because a patch that guesses corrupts a file without a sound.

Inline

TextDiff.inline("The quick brown fox", "The slow red fox")
// The [-quick brown-]{+slow red+} fox

Both versions in one line, in wdiff's notation. Whitespace between two changed words belongs to the change, so a replaced phrase is one bracket, not one per word.

Stats

let stats = TextDiff.stats(old, new)
stats.added, stats.removed, stats.changed, stats.unchanged, stats.similarity

Within each run of changes, deletes and inserts pair off as changed; the leftovers are added or removed. similarity is the unchanged share of the longer text, 0 to 1, and 1 for two empty texts.

How alike

Levenshtein.distance("kitten", "sitting")     // 3
Levenshtein.similarity("kitten", "sitting")   // 0.571 — the unedited share of the longer
JaroWinkler.similarity("DWAYNE", "DUANE")     // 0.840
JaroWinkler.jaro("DIXON", "DICKSONX")         // 0.767, without the prefix bonus

Levenshtein counts characters as a person sees them — grapheme clusters — so café and cafe are one edit apart however the accent was encoded. Jaro–Winkler suits names and short labels: characters that match near each other, less the ones out of order, plus a bonus for a shared prefix of up to four characters. The bonus is applied whatever the base score, as the common implementations do; the published test values are the same either way.

Which one they meant

Fuzzy.bestMatch("Genva", in: cities)                       // Geneva
Fuzzy.rank("Genva", in: cities, threshold: 0.6)            // every candidate at or above, best first
Fuzzy.score("zurich", "Zürich")                            // 1
Fuzzy.Options(ignoreCase: false, ignoreDiacritics: false, algorithm: .levenshtein)

Query and candidates are folded first — case and diacritics, both on by default — and an exact match after folding scores 1 outright. Ties keep list order, so the answer is the same every run.

Tested

60 tests, fixed strings with one right answer each: every shape of change round-tripped through unified and Patch.apply at three context widths, insertion at the very start and end, git's headers and markers, a mismatch refused with its line, hunks out of range and overlapping; words joining back to the exact original; combining marks, emoji, RTL and CRLF as single characters; the published Levenshtein and Jaro–Winkler values to three decimals; folding on and off; ties; ten thousand lines diffed, rendered and re-applied under two seconds; every model through JSON.

Licence

MIT.

About

Line, word and character diffs, unified patches that apply, and similarity — Levenshtein, Jaro-Winkler, fuzzy matching — as typed values, on any Apple platform.

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages