Standard library · Basics

text.diff

Comparing two texts line by line, printing the difference as a unified diff and measuring how alike they are.

import text.diff

The diff package finds what changed between two texts. It compares them line by line, finds the shortest list of lines to remove and add, and can print the result as a unified diff, the format of diff -u and git diff: a --- and a +++ header, then hunks that start with @@ -1,3 +1,4 @@ and mark lines with , - and +. The output follows Python's difflib.unified_diff exactly, so tools that read one read the other. Use it for a test that compares generated output with an expected file, for a build step that shows what a generator changed, and for an update check that asks whether anything is different.

There are two levels. Unified and UnifiedText give the finished lines; Opcodes gives the edit script itself, a list of Opcode records that say "lines a[i1:i2] were replaced by b[j1:j2]", for a program that wants to draw the difference its own way. Ratio and RatioText give a number between 0 and 1 for how alike two inputs are.

What people get wrong:

  • Expecting nothing back to mean an error. When both inputs are equal, Unified returns an empty list and UnifiedText an empty string, not even a header. That is how you test for "no difference".
  • Passing text where lines are expected. Unified takes Vector<str>; split a text with Lines first, or call UnifiedText. Lines drops the last empty piece after a final newline and a trailing \r on each line, so files with Windows line ends compare equal to files with Unix ones.
  • Reading the ratio as an edit distance. Ratio is 2*M/T, where M is the number of lines the two share in order and T the total of both lengths. It is built from a longest common subsequence, so it can be a little higher than difflib.SequenceMatcher.ratio(), which stops early on some inputs.
  • Diffing huge, completely different inputs. The search stops after 3000 edits and then treats what is left as one block replaced by another. The result is still a correct diff, only not the shortest.
  • Forgetting to free. Lines, Unified, Opcodes and GroupedOpcodes return vectors the caller frees (and each inner vector of GroupedOpcodes).

Showing what changed between two files

Read both files, hand them to UnifiedText with their names for the header, and print the answer when it is not empty. The third argument after the names is the number of unchanged lines to show around each change (3 is the usual choice, 0 shows only the changes).

text-diff-guide-files.salam
import text.diff
import fs.file
import fs.path
import os.temp

func main:
    work := temp.Directory()
    defer temp.RemoveDir(work)
    left := path.Join(work, "config.old")
    right := path.Join(work, "config.new")
    file.WriteText(left, "port=80\nhost=a\nmode=safe\n")
    file.WriteText(right, "port=8080\nhost=a\nmode=safe\ndebug=1\n")
    patch := diff.UnifiedText(file.ReadText(left), file.ReadText(right), "config.old", "config.new", 1)
    if patch.len() == 0:
        println "the files are the same"
    else:
        print patch
    end
    println "same file:", diff.UnifiedText("x\n", "x\n", "a", "b", 3).len() == 0
end
Output--- config.old +++ config.new @@ -1,3 +1,4 @@ -port=80 +port=8080 host=a mode=safe +debug=1 same file: true

Suggesting the closest name

RatioText turns "how alike are these two words" into a number, so a program can answer a typo with "did you mean". The example below tries each option, keeps the one with the highest score and only suggests it when the score is at least 0.6. Ratio does the same for lists of lines: three of the four lines are shared, so the score is 0.75.

text-diff-guide-similar.salam
import text.diff

func closest(word: str, options: str[]): str:
    mut best := ""
    mut best_score := 0.0
    each option in options:
        score := diff.RatioText(word, option)
        if score > best_score:
            best_score = score
            best = option
        end
    end
    ret best
end

func main:
    commands := ["start", "stop", "status", "restart", "version"]
    each typed in ["starts", "stops", "restarts", "xyz"]:
        guess := closest(typed, commands)
        score := guess.len() > 0 ? diff.RatioText(typed, guess) : 0.0
        if score >= 0.6:
            println typed, "->", guess
        else:
            println typed, "-> no suggestion"
        end
    end

    old := diff.Lines("a\nb\nc\nd\n")
    fresh := diff.Lines("a\nb\nx\nd\n")
    defer old.free()
    defer fresh.free()
    println "lines in common:", diff.Ratio(old, fresh)
end
Outputstarts -> start stops -> stop restarts -> restart xyz -> no suggestion lines in common: 0.75

Types

Opcode

struct Opcode
One step of the edit script: what happened to a[a_start:a_stop] compared with b[b_start:b_stop]. The range of the second input is empty for a delete, and the range of the first input is empty for an insert.
NameTypeDescription
tagint = 0One of Equal, Replace, Delete and Insert.
a_startint = 0Index of the first line of the first input this step covers.
a_stopint = 0Index after the last line of the first input this step covers.
b_startint = 0Index of the first line of the second input this step covers.
b_stopint = 0Index after the last line of the second input this step covers.
text-diff-Opcodes.salam
import text.diff
import str

func name(tag: int): str:
    switch tag:
        diff.Equal:
            ret "equal"
        end
        diff.Replace:
            ret "replace"
        end
        diff.Delete:
            ret "delete"
        end
        else:
            ret "insert"
        end
    end
end

func main:
    a := diff.Lines("one\ntwo\nthree\nfour\n")
    b := diff.Lines("one\n2\nthree\nfour\nfive\n")
    defer a.free()
    defer b.free()
    ops := diff.Opcodes(a, b)
    defer ops.free()
    each op in ops:
        println name(op.tag), "a[" + str.FromInt(op.a_start as i64) + ":" + str.FromInt(op.a_stop as i64) + "]", "b[" + str.FromInt(op.b_start as i64) + ":" + str.FromInt(op.b_stop as i64) + "]"
    end
    groups := diff.GroupedOpcodes(a, b, 0)
    println "hunks:", groups.len()
    each g in groups:
        g.free()
    end
    groups.free()
end
Outputequal a[0:1] b[0:1] replace a[1:2] b[1:2] equal a[2:4] b[2:4] insert a[4:4] b[4:5] hunks: 2

Constants and variables

Equal

const Equal: i32 = 0
The opcode tag for lines that are the same in both inputs.

The example for this item is shown with the ones above: text-diff-Opcodes.salam

Replace

const Replace: i32 = 1
The opcode tag for lines of the first input that were replaced by different lines of the second.

The example for this item is shown with the ones above: text-diff-Opcodes.salam

Delete

const Delete: i32 = 2
The opcode tag for lines that are only in the first input.

The example for this item is shown with the ones above: text-diff-Opcodes.salam

Insert

const Insert: i32 = 3
The opcode tag for lines that are only in the second input.

The example for this item is shown with the ones above: text-diff-Opcodes.salam

Functions

Opcodes

func Opcodes(a: Vector<str>, b: Vector<str>): Vector<Opcode>
The edit script that turns a into b: opcodes that follow each other and together cover both inputs from start to end. Neighbouring removals and additions are joined into one Replace. The caller frees the vector.

The example for this item is shown with the ones above: text-diff-Opcodes.salam

GroupedOpcodes

func GroupedOpcodes(a: Vector<str>, b: Vector<str>, context: int): Vector<Vector<Opcode>>
The opcodes cut into hunks, each with at most context unchanged lines on either side of a change, and no hunk at all when the inputs are equal. A negative context counts as 0. The caller frees the outer vector and every inner one.

The example for this item is shown with the ones above: text-diff-Opcodes.salam

Unified

func Unified(a: Vector<str>, b: Vector<str>, from_name: str, to_name: str, context: int): Vector<str>
The difference as the lines of a unified diff: the --- and +++ headers with the two names, then each hunk with its @@ line. The lines have no line terminator. Equal inputs give an empty vector. The caller frees the vector.
text-diff-Unified.salam
import text.diff

func main:
    old := diff.Lines("red\ngreen\nblue\n")
    fresh := diff.Lines("red\nyellow\nblue\nblack\n")
    defer old.free()
    defer fresh.free()
    lines := diff.Unified(old, fresh, "old.txt", "new.txt", 1)
    defer lines.free()
    each line in lines:
        println line
    end
    print diff.UnifiedText("a\nb\n", "a\nc\n", "x", "y", 0)
end
Output--- old.txt +++ new.txt @@ -1,3 +1,4 @@ red -green +yellow blue +black --- x +++ y @@ -2 +2 @@ -b +c

Lines

func Lines(text: str): Vector<str>
Splits a text into lines: on \n, dropping the empty piece after a final newline and a \r at the end of a line. An empty text has no lines. The caller frees the vector.

The example for this item is shown with the ones above: text-diff-Unified.salam

UnifiedText

func UnifiedText(a: str, b: str, from_name: str, to_name: str, context: int): str
The same as Unified for two texts, as one string with a newline after every line; empty when the texts have the same lines.

The example for this item is shown with the ones above: text-diff-Unified.salam

Ratio

func Ratio(a: Vector<str>, b: Vector<str>): f64
How alike two lists of lines are, from 0 (nothing shared) to 1 (the same): twice the number of lines they share in order, divided by the sum of their lengths. Two empty lists give 1.
text-diff-Ratio.salam
import text.diff

func main:
    println diff.RatioText("kitten", "sitting")
    println diff.RatioText("same", "same"), diff.RatioText("abc", "xyz")
    a := diff.Lines("a\nb\nc\nd\n")
    b := diff.Lines("a\nb\nx\nd\n")
    defer a.free()
    defer b.free()
    println diff.Ratio(a, b)
end
Output0.6153846153846154 1 0 0.75

RatioText

func RatioText(a: str, b: str): f64
The same measure for two strings, counted in characters (code points, so Persian text is counted by letter). Use it for fuzzy matching of names and commands.

The example for this item is shown with the ones above: text-diff-Ratio.salam