<!-- canonical: https://efficientnewlanguage.org/eml-p/projects/P029-line-editor/ | updated: 2026-10-06 -->

# P029 Line editor

A text of up to 30 lines edited with short commands - append, insert, replace, delete a line or a range, list, find - with undo and redo on two stacks. Every edit is one splice, and undoing it is the same splice reversed, so the stacks hold changes rather than copies of the text.

EML-P project `projects/line-editor` in the EML language repo: 2 module(s), entry `main.eml`, terminal UI. There, `eml project run projects/line-editor` runs it and `eml project verify projects/line-editor` replays every session under CPython (two hash seeds) and in the interpreter; the site build replays every session in the interpreter again and publishes a session only if its screen matches.

Built on verified corpus cases: simple-stack (https://efficientnewlanguage.org/cases/042-simple-stack/).

## Sessions

### bad-input - interpreter: byte-equal to the golden

Input:

```text

x
l
u
y
d 1
r 1 new
i 2 first
i one two
a
l
a zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz
d 2-1
d x
d 1-x
l 5
l a-b
f
f zzz
a line 2
a line 3
a line 4
a line 5
a line 6
a line 7
a line 8
a line 9
a line 10
a line 11
a line 12
a line 13
a line 14
a line 15
a line 16
a line 17
a line 18
a line 19
a line 20
a line 21
a line 22
a line 23
a line 24
a line 25
a line 26
a line 27
a line 28
a line 29
a line 30
a one more
i 1 also no
r 30 the last line
l 29-30
APPEND
Q
```

Screen:

```text
== Line editor ==
A text of up to 30 lines of at most 60 characters. Commands:
  a text       append a line at the end
  i N text     insert a line before line N
  r N text     replace line N
  d N, d N-M   delete line N, or lines N to M
  l, l N-M     list the text, or lines N to M
  f text       find the lines that contain the text (any case)
  u, y         undo, redo
  h, q         this help, quit
edit (0 lines)> 
edit (0 lines)> x
Unknown command 'x'; type h for help.
edit (0 lines)> l
The text is empty.
edit (0 lines)> u
Nothing to undo.
edit (0 lines)> y
Nothing to redo.
edit (0 lines)> d 1
The text is empty.
edit (0 lines)> r 1 new
The text is empty.
edit (0 lines)> i 2 first
A new line can go in at 1 to 1.
edit (0 lines)> i one two
Type a line number and the text, such as i 2 new text.
edit (0 lines)> a
Line 1 added.
edit (1 line)> l
  1 |
edit (1 line)> a zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz
That line has 61 characters; a line can have at most 60.
edit (1 line)> d 2-1
There is only line 1.
edit (1 line)> d x
Type a line number or a range, such as d 2 or d 2-4.
edit (1 line)> d 1-x
Type a line number or a range, such as d 2 or d 2-4.
edit (1 line)> l 5
There is only line 1.
edit (1 line)> l a-b
Type l, or a line number or a range, such as l 2-4.
edit (1 line)> f
Type the text to find, such as f cat.
edit (1 line)> f zzz
No line contains 'zzz'.
edit (1 line)> a line 2
Line 2 added.
edit (2 lines)> a line 3
Line 3 added.
edit (3 lines)> a line 4
Line 4 added.
edit (4 lines)> a line 5
Line 5 added.
edit (5 lines)> a line 6
Line 6 added.
edit (6 lines)> a line 7
Line 7 added.
edit (7 lines)> a line 8
Line 8 added.
edit (8 lines)> a line 9
Line 9 added.
edit (9 lines)> a line 10
Line 10 added.
edit (10 lines)> a line 11
Line 11 added.
edit (11 lines)> a line 12
Line 12 added.
edit (12 lines)> a line 13
Line 13 added.
edit (13 lines)> a line 14
Line 14 added.
edit (14 lines)> a line 15
Line 15 added.
edit (15 lines)> a line 16
Line 16 added.
edit (16 lines)> a line 17
Line 17 added.
edit (17 lines)> a line 18
Line 18 added.
edit (18 lines)> a line 19
Line 19 added.
edit (19 lines)> a line 20
Line 20 added.
edit (20 lines)> a line 21
Line 21 added.
edit (21 lines)> a line 22
Line 22 added.
edit (22 lines)> a line 23
Line 23 added.
edit (23 lines)> a line 24
Line 24 added.
edit (24 lines)> a line 25
Line 25 added.
edit (25 lines)> a line 26
Line 26 added.
edit (26 lines)> a line 27
Line 27 added.
edit (27 lines)> a line 28
Line 28 added.
edit (28 lines)> a line 29
Line 29 added.
edit (29 lines)> a line 30
Line 30 added.
edit (30 lines)> a one more
The text has 30 lines, the most it can hold.
edit (30 lines)> i 1 also no
The text has 30 lines, the most it can hold.
edit (30 lines)> r 30 the last line
Line 30 replaced.
edit (30 lines)> l 29-30
  29 | line 29
  30 | the last line
edit (30 lines)> APPEND
The text has 30 lines, the most it can hold.
edit (30 lines)> Q
Bye.
```

### basic - interpreter: byte-equal to the golden

Input:

```text
a Shopping list
a milk
a eggs
a bread
l
i 2 fruit:
r 3 2 apples
d 4
l
u
l
u
u
y
l
f MILK
d 2-4
l
u
a   indented line
y
l 4-6
h
q
```

Screen:

```text
== Line editor ==
A text of up to 30 lines of at most 60 characters. Commands:
  a text       append a line at the end
  i N text     insert a line before line N
  r N text     replace line N
  d N, d N-M   delete line N, or lines N to M
  l, l N-M     list the text, or lines N to M
  f text       find the lines that contain the text (any case)
  u, y         undo, redo
  h, q         this help, quit
edit (0 lines)> a Shopping list
Line 1 added.
edit (1 line)> a milk
Line 2 added.
edit (2 lines)> a eggs
Line 3 added.
edit (3 lines)> a bread
Line 4 added.
edit (4 lines)> l
  1 | Shopping list
  2 | milk
  3 | eggs
  4 | bread
edit (4 lines)> i 2 fruit:
Inserted as line 2.
edit (5 lines)> r 3 2 apples
Line 3 replaced.
edit (5 lines)> d 4
Deleted line 4.
edit (4 lines)> l
  1 | Shopping list
  2 | fruit:
  3 | 2 apples
  4 | bread
edit (4 lines)> u
Undid: delete line 4.
edit (5 lines)> l
  1 | Shopping list
  2 | fruit:
  3 | 2 apples
  4 | eggs
  5 | bread
edit (5 lines)> u
Undid: replace line 3.
edit (5 lines)> u
Undid: insert line 2.
edit (4 lines)> y
Redid: insert line 2.
edit (5 lines)> l
  1 | Shopping list
  2 | fruit:
  3 | milk
  4 | eggs
  5 | bread
edit (5 lines)> f MILK
1 line contains 'MILK':
  3 | milk
edit (5 lines)> d 2-4
Deleted lines 2-4.
edit (2 lines)> l
  1 | Shopping list
  2 | bread
edit (2 lines)> u
Undid: delete lines 2-4.
edit (5 lines)> a   indented line
Line 6 added.
edit (6 lines)> y
Nothing to redo.
edit (6 lines)> l 4-6
  4 | eggs
  5 | bread
  6 |   indented line
edit (6 lines)> h
  a text       append a line at the end
  i N text     insert a line before line N
  r N text     replace line N
  d N, d N-M   delete line N, or lines N to M
  l, l N-M     list the text, or lines N to M
  f text       find the lines that contain the text (any case)
  u, y         undo, redo
  h, q         this help, quit
edit (6 lines)> q
Bye.
```

## Modules

### main.eml

```eml
# P029 line editor: a text of up to 30 lines, edited with short commands -
# append, insert, replace, delete, list and find - with undo and redo.
import buffer

30 => most_lines
60 => widest_line

def split_word(s):
    # [the first word, the rest after the one space that follows it]; spaces
    # before the word are skipped, the rest is kept as typed.
    0 => i
    while i < len(s) and s[i] == " ":
        i + 1 => i
    i => j
    while j < len(s) and s[j] != " ":
        j + 1 => j
    if j < len(s):
        return [s[i:j], s[j + 1:len(s)]]
    return [s[i:j], ""]

def number(s):
    # 1 to 3 digits as a number; -1 for anything else.
    if s == "" or len(s) > 3:
        return 0 - 1
    0 => n
    for c in s:
        if not (c in "0123456789"):
            return 0 - 1
        n * 10 + int(c) => n
    return n

def line_range(s):
    # [first, last] for "N" or "N-M"; [] for anything else.
    0 => dash
    while dash < len(s) and s[dash] != "-":
        dash + 1 => dash
    if dash == len(s):
        number(s) => n
        if n == 0 - 1:
            return []
        return [n, n]
    number(s[0:dash]) => a
    number(s[dash + 1:len(s)]) => b
    if a == 0 - 1 or b == 0 - 1:
        return []
    return [a, b]

def show(lines, n):
    # Line n, numbered: "  3 | text".
    str(n) => num
    while len(num) < len(str(len(lines))):
        " " + num => num
    "  " + num + " |" => out
    if lines[n - 1] != "":
        out + " " + lines[n - 1] => out
    out ^0

def which_lines(first, last):
    if first == last:
        return "line " + str(first)
    return "lines " + str(first) + "-" + str(last)

def out_of_range(lines):
    if len(lines) == 0:
        "The text is empty." ^0
    elif len(lines) == 1:
        "There is only line 1." ^0
    else:
        ("Line numbers run from 1 to " + str(len(lines)) + ".") ^0

def fits(lines, text):
    # Whether one more line of this text can go in; says why not.
    if len(lines) >= most_lines:
        ("The text has " + str(most_lines) + " lines, the most it can hold.") ^0
        return False
    if len(text) > widest_line:
        ("That line has " + str(len(text)) + " characters; a line can have at most " + str(widest_line) + ".") ^0
        return False
    return True

def help():
    "  a text       append a line at the end" ^0
    "  i N text     insert a line before line N" ^0
    "  r N text     replace line N" ^0
    "  d N, d N-M   delete line N, or lines N to M" ^0
    "  l, l N-M     list the text, or lines N to M" ^0
    "  f text       find the lines that contain the text (any case)" ^0
    "  u, y         undo, redo" ^0
    "  h, q         this help, quit" ^0

def step(state, command):
    # Carries out one command; returns the new state [lines, undo, redo].
    state[0] => lines
    split_word(command) => parts
    buffer.lower(parts[0]) => verb
    parts[1] => rest
    if verb == "a" or verb == "append":
        if fits(lines, rest):
            buffer.edit(state, [len(lines), [], [rest], "append line " + str(len(lines) + 1)]) => state
            ("Line " + str(len(lines) + 1) + " added.") ^0
    elif verb == "i" or verb == "insert" or verb == "r" or verb == "replace":
        split_word(rest) => args
        number(args[0]) => n
        if n == 0 - 1:
            ("Type a line number and the text, such as " + verb + " 2 new text.") ^0
        elif verb == "i" or verb == "insert":
            if n < 1 or n > len(lines) + 1:
                ("A new line can go in at 1 to " + str(len(lines) + 1) + ".") ^0
            elif fits(lines, args[1]):
                buffer.edit(state, [n - 1, [], [args[1]], "insert line " + str(n)]) => state
                ("Inserted as line " + str(n) + ".") ^0
        elif n < 1 or n > len(lines):
            out_of_range(lines)
        elif len(args[1]) > widest_line:
            ("That line has " + str(len(args[1])) + " characters; a line can have at most " + str(widest_line) + ".") ^0
        else:
            buffer.edit(state, [n - 1, [lines[n - 1]], [args[1]], "replace line " + str(n)]) => state
            ("Line " + str(n) + " replaced.") ^0
    elif verb == "d" or verb == "delete":
        line_range(rest) => r
        if len(r) == 0:
            "Type a line number or a range, such as d 2 or d 2-4." ^0
        elif r[0] < 1 or r[1] > len(lines) or r[0] > r[1]:
            out_of_range(lines)
        else:
            buffer.edit(state, [r[0] - 1, lines[r[0] - 1:r[1]], [], "delete " + which_lines(r[0], r[1])]) => state
            ("Deleted " + which_lines(r[0], r[1]) + ".") ^0
    elif verb == "l" or verb == "list":
        if len(lines) == 0:
            "The text is empty." ^0
        elif rest == "":
            for n in [1:len(lines)]:
                show(lines, n)
        else:
            line_range(rest) => r
            if len(r) == 0:
                "Type l, or a line number or a range, such as l 2-4." ^0
            elif r[0] < 1 or r[1] > len(lines) or r[0] > r[1]:
                out_of_range(lines)
            else:
                for n in [r[0]:r[1]]:
                    show(lines, n)
    elif verb == "f" or verb == "find":
        if rest == "":
            "Type the text to find, such as f cat." ^0
        else:
            buffer.finds(lines, rest) => hits
            if len(hits) == 0:
                ("No line contains '" + rest + "'.") ^0
            else:
                if len(hits) == 1:
                    ("1 line contains '" + rest + "':") ^0
                else:
                    (str(len(hits)) + " lines contain '" + rest + "':") ^0
                for n in hits:
                    show(lines, n)
    elif verb == "u" or verb == "undo":
        buffer.undo(state) => result
        if result[1] == None:
            "Nothing to undo." ^0
        else:
            result[0] => state
            ("Undid: " + result[1][3] + ".") ^0
    elif verb == "y" or verb == "redo":
        buffer.redo(state) => result
        if result[1] == None:
            "Nothing to redo." ^0
        else:
            result[0] => state
            ("Redid: " + result[1][3] + ".") ^0
    elif verb == "h" or verb == "help":
        help()
    elif verb != "":
        ("Unknown command '" + parts[0] + "'; type h for help.") ^0
    return state

"== Line editor ==" ^0
("A text of up to " + str(most_lines) + " lines of at most " + str(widest_line) + " characters. Commands:") ^0
help()
[[], [], []] => state
True => running
while running:
    "lines" => unit
    if len(state[0]) == 1:
        "line" => unit
    input("edit (" + str(len(state[0])) + " " + unit + ")> ") => command
    buffer.lower(split_word(command)[0]) => verb
    if verb == "q" or verb == "quit":
        False => running
    else:
        step(state, command) => state
"Bye." ^0
```

Python projection of main.eml:

```python
import buffer
most_lines = 30
widest_line = 60

def split_word(s):
    i = 0
    while i < len(s) and s[i] == " ":
        i = i + 1
    j = i
    while j < len(s) and s[j] != " ":
        j = j + 1
    if j < len(s):
        return [s[i:j], s[j + 1:len(s)]]
    return [s[i:j], ""]

def number(s):
    if s == "" or len(s) > 3:
        return 0 - 1
    n = 0
    for c in s:
        if not c in "0123456789":
            return 0 - 1
        n = n * 10 + int(c)
    return n

def line_range(s):
    dash = 0
    while dash < len(s) and s[dash] != "-":
        dash = dash + 1
    if dash == len(s):
        n = number(s)
        if n == 0 - 1:
            return []
        return [n, n]
    a = number(s[0:dash])
    b = number(s[dash + 1:len(s)])
    if a == 0 - 1 or b == 0 - 1:
        return []
    return [a, b]

def show(lines, n):
    num = str(n)
    while len(num) < len(str(len(lines))):
        num = " " + num
    out = "  " + num + " |"
    if lines[n - 1] != "":
        out = out + " " + lines[n - 1]
    print(out)

def which_lines(first, last):
    if first == last:
        return "line " + str(first)
    return "lines " + str(first) + "-" + str(last)

def out_of_range(lines):
    if len(lines) == 0:
        print("The text is empty.")
    elif len(lines) == 1:
        print("There is only line 1.")
    else:
        print("Line numbers run from 1 to " + str(len(lines)) + ".")

def fits(lines, text):
    if len(lines) >= most_lines:
        print("The text has " + str(most_lines) + " lines, the most it can hold.")
        return False
    if len(text) > widest_line:
        print("That line has " + str(len(text)) + " characters; a line can have at most " + str(widest_line) + ".")
        return False
    return True

def help():
    print("  a text       append a line at the end")
    print("  i N text     insert a line before line N")
    print("  r N text     replace line N")
    print("  d N, d N-M   delete line N, or lines N to M")
    print("  l, l N-M     list the text, or lines N to M")
    print("  f text       find the lines that contain the text (any case)")
    print("  u, y         undo, redo")
    print("  h, q         this help, quit")

def step(state, command):
    lines = state[0]
    parts = split_word(command)
    verb = buffer.lower(parts[0])
    rest = parts[1]
    if verb == "a" or verb == "append":
        if fits(lines, rest):
            state = buffer.edit(state, [len(lines), [], [rest], "append line " + str(len(lines) + 1)])
            print("Line " + str(len(lines) + 1) + " added.")
    elif verb == "i" or verb == "insert" or verb == "r" or verb == "replace":
        args = split_word(rest)
        n = number(args[0])
        if n == 0 - 1:
            print("Type a line number and the text, such as " + verb + " 2 new text.")
        elif verb == "i" or verb == "insert":
            if n < 1 or n > len(lines) + 1:
                print("A new line can go in at 1 to " + str(len(lines) + 1) + ".")
            elif fits(lines, args[1]):
                state = buffer.edit(state, [n - 1, [], [args[1]], "insert line " + str(n)])
                print("Inserted as line " + str(n) + ".")
        elif n < 1 or n > len(lines):
            out_of_range(lines)
        elif len(args[1]) > widest_line:
            print("That line has " + str(len(args[1])) + " characters; a line can have at most " + str(widest_line) + ".")
        else:
            state = buffer.edit(state, [n - 1, [lines[n - 1]], [args[1]], "replace line " + str(n)])
            print("Line " + str(n) + " replaced.")
    elif verb == "d" or verb == "delete":
        r = line_range(rest)
        if len(r) == 0:
            print("Type a line number or a range, such as d 2 or d 2-4.")
        elif r[0] < 1 or r[1] > len(lines) or r[0] > r[1]:
            out_of_range(lines)
        else:
            state = buffer.edit(state, [r[0] - 1, lines[r[0] - 1:r[1]], [], "delete " + which_lines(r[0], r[1])])
            print("Deleted " + which_lines(r[0], r[1]) + ".")
    elif verb == "l" or verb == "list":
        if len(lines) == 0:
            print("The text is empty.")
        elif rest == "":
            for n in range(1, len(lines)+1):
                show(lines, n)
        else:
            r = line_range(rest)
            if len(r) == 0:
                print("Type l, or a line number or a range, such as l 2-4.")
            elif r[0] < 1 or r[1] > len(lines) or r[0] > r[1]:
                out_of_range(lines)
            else:
                for n in range(r[0], r[1]+1):
                    show(lines, n)
    elif verb == "f" or verb == "find":
        if rest == "":
            print("Type the text to find, such as f cat.")
        else:
            hits = buffer.finds(lines, rest)
            if len(hits) == 0:
                print("No line contains '" + rest + "'.")
            else:
                if len(hits) == 1:
                    print("1 line contains '" + rest + "':")
                else:
                    print(str(len(hits)) + " lines contain '" + rest + "':")
                for n in hits:
                    show(lines, n)
    elif verb == "u" or verb == "undo":
        result = buffer.undo(state)
        if result[1] == None:
            print("Nothing to undo.")
        else:
            state = result[0]
            print("Undid: " + result[1][3] + ".")
    elif verb == "y" or verb == "redo":
        result = buffer.redo(state)
        if result[1] == None:
            print("Nothing to redo.")
        else:
            state = result[0]
            print("Redid: " + result[1][3] + ".")
    elif verb == "h" or verb == "help":
        help()
    elif verb != "":
        print("Unknown command '" + parts[0] + "'; type h for help.")
    return state

print("== Line editor ==")
print("A text of up to " + str(most_lines) + " lines of at most " + str(widest_line) + " characters. Commands:")
help()
state = [[], [], []]
running = True
while running:
    unit = "lines"
    if len(state[0]) == 1:
        unit = "line"
    command = input("edit (" + str(len(state[0])) + " " + unit + ")> ")
    verb = buffer.lower(split_word(command)[0])
    if verb == "q" or verb == "quit":
        running = False
    else:
        state = step(state, command)
print("Bye.")
```

### buffer.eml

```eml
# P029 line editor - the text and its history. Every edit is one splice:
# at line index `at`, the lines `removed` come out and the lines `inserted`
# go in. A change is [at, removed, inserted, description]. Undoing a change
# is the same splice with removed and inserted swapped, so the undo and redo
# stacks only hold changes, never copies of the whole text.

def splice(lines, at, count, new_lines):
    # The lines with `count` of them taken out at index `at` and new_lines
    # put in their place.
    return lines[0:at] + new_lines + lines[at + count:len(lines)]

def apply(lines, change):
    return splice(lines, change[0], len(change[1]), change[2])

def inverse(change):
    return [change[0], change[2], change[1], change[3]]

def top(stack):
    return stack[len(stack) - 1]

def without_top(stack):
    # The stack popped: a slice rebuild, as in the corpus case simple-stack.
    return stack[0:len(stack) - 1]

def edit(state, change):
    # state is [lines, undo stack, redo stack]. A new edit is applied, goes
    # on the undo stack, and empties the redo stack.
    return [apply(state[0], change), state[1] + [change], []]

def undo(state):
    # [new state, the change undone], or [state, None] when there is nothing
    # to undo.
    if len(state[1]) == 0:
        return [state, None]
    top(state[1]) => change
    return [[apply(state[0], inverse(change)), without_top(state[1]), state[2] + [change]], change]

def redo(state):
    if len(state[2]) == 0:
        return [state, None]
    top(state[2]) => change
    return [[apply(state[0], change), state[1] + [change], without_top(state[2])], change]

def finds(lines, part):
    # The 1-based numbers of the lines that contain part, in any case.
    lower(part) => p
    [] => out
    0 => i
    while i < len(lines):
        lower(lines[i]) => s
        0 => k
        False => found
        while not found and k + len(p) <= len(s):
            if s[k:k + len(p)] == p:
                True => found
            k + 1 => k
        if found:
            out + [i + 1] => out
        i + 1 => i
    return out

def lower(s):
    "ABCDEFGHIJKLMNOPQRSTUVWXYZ" => upper
    "abcdefghijklmnopqrstuvwxyz" => small
    "" => out
    for c in s:
        0 => k
        while k < 26 and upper[k] != c:
            k + 1 => k
        if k < 26:
            out + small[k] => out
        else:
            out + c => out
    return out
```

Python projection of buffer.eml:

```python
def splice(lines, at, count, new_lines):
    return lines[0:at] + new_lines + lines[at + count:len(lines)]

def apply(lines, change):
    return splice(lines, change[0], len(change[1]), change[2])

def inverse(change):
    return [change[0], change[2], change[1], change[3]]

def top(stack):
    return stack[len(stack) - 1]

def without_top(stack):
    return stack[0:len(stack) - 1]

def edit(state, change):
    return [apply(state[0], change), state[1] + [change], []]

def undo(state):
    if len(state[1]) == 0:
        return [state, None]
    change = top(state[1])
    return [[apply(state[0], inverse(change)), without_top(state[1]), state[2] + [change]], change]

def redo(state):
    if len(state[2]) == 0:
        return [state, None]
    change = top(state[2])
    return [[apply(state[0], change), state[1] + [change], without_top(state[2])], change]

def finds(lines, part):
    p = lower(part)
    out = []
    i = 0
    while i < len(lines):
        s = lower(lines[i])
        k = 0
        found = False
        while not found and k + len(p) <= len(s):
            if s[k:k + len(p)] == p:
                found = True
            k = k + 1
        if found:
            out = out + [i + 1]
        i = i + 1
    return out

def lower(s):
    upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    small = "abcdefghijklmnopqrstuvwxyz"
    out = ""
    for c in s:
        k = 0
        while k < 26 and upper[k] != c:
            k = k + 1
        if k < 26:
            out = out + small[k]
        else:
            out = out + c
    return out
```

## README

# P029 - Line editor

A text of up to 30 lines, edited with short commands: append a line, insert
one before line N, replace line N, delete a line or a range, list all or part
of the text, and find the lines that contain some text. Every edit can be
undone and redone.

- `main.eml` - the command line: reading a command, its checks, and what the
  screen shows
- `buffer.eml` - the text and its history: the splice, undo and redo, and
  finding text

How each part works:

- Every edit is one splice: at one place in the text, some lines come out and
  some go in. Appending, inserting, replacing and deleting are all splices,
  and each is kept as a change - where, the lines that came out, the lines
  that went in, and a description such as `delete lines 2-4`.
- Undo and redo are two stacks of changes. Undoing a change is the same
  splice with the lines in and out swapped; the change then moves to the redo
  stack, and redoing it moves it back. A new edit empties the redo stack, as
  in most editors. The stacks hold changes, never copies of the whole text.
  They are lists popped by a slice rebuild, as in the corpus case
  `simple-stack`.
- A command is its first word, in any case (`a` or `append`, and so on);
  the text after the one space that follows the command - or the line number
  - is kept as typed, leading spaces included, so a line can be indented.
- Finding ignores case. Lines are listed with their numbers, right-aligned.

What is checked: the text holds at most 30 lines of at most 60 characters; a
new line can go in at 1 to one past the last line; a line to replace or
delete, or a range (`N-M`, with N not after M), must exist. An unknown
command says so and points to `h`; an empty command does nothing; `u` and
`y` say when there is nothing to undo or redo.

Sessions: `sessions/basic.in` writes a four-line shopping list, inserts,
replaces and deletes, undoes three times and redoes once, finds `MILK`,
deletes a range and undoes it, appends an indented line (which empties the
redo stack - the next `y` has nothing to redo), lists a range and shows the
help; `sessions/bad-input.in` tries every command on an empty text, inserts
past the end, gives a word for a line number, appends an empty line and a
line of 61 characters, deletes a range that runs backwards and ranges that
are not numbers, lists line 5 of one, searches for nothing and for text that
is not there, fills the text to 30 lines, tries to add a 31st, replaces the
last line, and types a command in capitals.

Built on the verified corpus case `simple-stack` (a list used as a stack,
pushed by adding a one-item list and popped by a slice rebuild).
